diff --git a/connector_pms/models/pms_room_type/pms_room_type.py b/connector_pms/models/pms_room_type/pms_room_type.py deleted file mode 100644 index 34ff3cf559c..00000000000 --- a/connector_pms/models/pms_room_type/pms_room_type.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright 2018 Alexandre Díaz -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import _, api, fields, models -from odoo.exceptions import ValidationError - - -class PmsRoomType(models.Model): - _inherit = "pms.room.type" - - channel_bind_ids = fields.One2many( - comodel_name="channel.pms.room.type", - inverse_name="odoo_id", - string="Channel PMS Bindings", - ) diff --git a/connector_pms_wubook/README.rst b/connector_pms_wubook/README.rst new file mode 100644 index 00000000000..3670a00fd04 --- /dev/null +++ b/connector_pms_wubook/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_wubook/__init__.py b/connector_pms_wubook/__init__.py new file mode 100644 index 00000000000..4e1fdbb2034 --- /dev/null +++ b/connector_pms_wubook/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import components +from . import models diff --git a/connector_pms_wubook/__manifest__.py b/connector_pms_wubook/__manifest__.py new file mode 100644 index 00000000000..e9a6e6839ae --- /dev/null +++ b/connector_pms_wubook/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "PMS Connector Wubook", + "summary": "Channel PMS connector Wubook", + "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", + "data/queue_job_function_data.xml", + "security/ir.model.access.csv", + "views/channel_wubook_backend_views.xml", + "views/channel_wubook_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/product_pricelist_views.xml", + "views/pms_availability_plan_views.xml", + ], + "demo": [], +} diff --git a/connector_pms_wubook/components/__init__.py b/connector_pms_wubook/components/__init__.py new file mode 100644 index 00000000000..8d82aaa150c --- /dev/null +++ b/connector_pms_wubook/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_wubook/components/adapter.py b/connector_pms_wubook/components/adapter.py new file mode 100644 index 00000000000..8f4eee1dbd3 --- /dev/null +++ b/connector_pms_wubook/components/adapter.py @@ -0,0 +1,254 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime +import xmlrpc.client + +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import AbstractComponent +from odoo.addons.connector_pms.components.adapter import ChannelAdapterError + + +class ChannelWubookAdapter(AbstractComponent): + _name = "channel.wubook.adapter" + _inherit = ["channel.adapter", "base.channel.wubook.connector"] + + _id = "id" + + _date_format = "%d/%m/%Y" + + def __init__(self, environment): + super().__init__(environment) + + self.url = self.backend_record.url + self.username = self.backend_record.username + self.password = self.backend_record.password + self.apikey = self.backend_record.pkey + self.property_code = self.backend_record.property_code + + def _exec(self, funcname, *args): + s = xmlrpc.client.Server(self.url) + res, token = s.acquire_token(self.username, self.password, self.apikey) + if res: + raise ChannelAdapterError(_("Error authorizing to endpoint. %s") % token) + + func = getattr(s, funcname) + try: + res, data = func(token, self.property_code, *args) + if res: + raise ChannelAdapterError( + _("Error executing function %s with params %s. %s") + % (funcname, args, data) + ) + return data + finally: + # TODO: reutilize token on multiple calls + res, info = s.release_token(token) + if res: + raise ChannelAdapterError(_("Error releasing token. %s") % info) + + def _prepare_field_type(self, field_data): + default_values = {} + fields = [] + for m in field_data: + if isinstance(m, tuple): + fields.append(m[0]) + default_values[m[0]] = m[1] + else: + fields.append(m) + + return fields, default_values + + def _prepare_parameters(self, values, mandatory, optional=None): + if not optional: + optional = [] + + mandatory, mandatory_default_values = self._prepare_field_type(mandatory) + optional, default_values = self._prepare_field_type(optional) + + default_values.update(mandatory_default_values) + + missing_fields = list(set(mandatory) - set(values)) + if missing_fields: + raise ChannelAdapterError(_("Missing mandatory fields %s") % missing_fields) + + mandatory_values = [values[x] for x in mandatory] + + optional_values = [] + found = False + for o in optional[::-1]: + if not found and o in values: + found = True + if found: + optional_values.append(values.get(o, default_values.get(o, False))) + + return mandatory_values + optional_values[::-1] + + def _normalize_value(self, value): + if isinstance(value, datetime.date): + value = value.strftime(self._date_format) + elif isinstance(value, bool): + value = value and 1 or 0 + elif isinstance(value, (int, str, list, tuple)): + pass + else: + raise Exception("Type '%s' not supported" % type(value)) + return value + + def _domain_to_normalized_dict(self, domain, interval_fields=None): + """Convert, if possible, standard Odoo domain to a dictionary. + To do so it is necessary to convert all operators to + equal '=' operator. + """ + if not interval_fields: + interval_fields = [] + else: + if not isinstance(interval_fields, (tuple, list)): + interval_fields = [interval_fields] + res = {} + ifields_check = {} + for elem in domain: + if len(elem) != 3: + raise ValidationError(_("Wrong domain clause format %s") % elem) + field, op, value = elem + if op == "=": + if field in interval_fields: + for postfix in ["from", "to"]: + field_field = "{}_{}".format(field, postfix) + ifields_check.setdefault(field, set()) + if field_field in ifields_check[field]: + raise ValidationError( + _("Interval field %s duplicated") % field_field + ) + ifields_check[field].add(field_field) + if field_field in res: + raise ValidationError( + _("Duplicated field %s") % field_field + ) + res[field_field] = self._normalize_value(value) + else: + if field in res: + raise ValidationError(_("Duplicated field %s") % field) + res[field] = self._normalize_value(value) + elif op == "!=": + if field in interval_fields: + raise ValidationError( + _("Operator {} not supported on interval fields {}").format( + op, field + ) + ) + if not isinstance(value, bool): + raise ValidationError( + _("Not equal operation not supported for non boolean fields") + ) + if field in res: + raise ValidationError(_("Duplicated field %s") % field) + res[field] = self._normalize_value(not value) + elif op == "in": + if field in interval_fields: + raise ValidationError( + _("Operator {} not supported on interval fields {}").format( + op, field + ) + ) + if not isinstance(value, (tuple, list)): + raise ValidationError( + _("Operator '%s' only supports tuples or lists, not %s") + % (op, type(value)) + ) + if field in res: + raise ValidationError(_("Duplicated field %s") % field) + res[field] = self._normalize_value(value) + elif op in (">", ">=", "<", "<="): + if field not in interval_fields: + raise ValidationError( + _("The operator %s is only supported on interval fields") % op + ) + if not isinstance( + value, (datetime.date, datetime.datetime, int, float) + ): + raise ValidationError( + _("Type {} not supported for operator {}").format( + type(value), op + ) + ) + if op in (">", "<"): + adj = 1 + if isinstance(value, (datetime.date, datetime.datetime)): + adj = datetime.timedelta(days=adj) + if op == "<": + op, value = "<=", value - adj + else: + op, value = ">=", value + adj + field_field = "{}_{}".format(field, op == ">=" and "from" or "to") + ifields_check.setdefault(field, set()) + if field_field in ifields_check[field]: + raise ValidationError( + _("Interval field %s duplicated") % field_field + ) + ifields_check[field].add(field_field) + if field_field in res: + raise ValidationError(_("Duplicated field %s") % field_field) + res[field_field] = self._normalize_value(value) + else: + raise ValidationError(_("Operator %s not supported") % op) + for field in interval_fields: + if field in ifields_check: + if len(ifields_check[field]) != 2: + raise ValidationError( + _( + "Interval field %s should have exactly 2 clauses on the domain" + ) + % field + ) + return res + + # def _check_format_domain_search_read(self, domain): + # values = {} + # for field, op, value in domain: + # if re.match("^(.+_)?(dfrom|dto)$", field): + # if op != "=": + # raise NotImplementedError( + # _("Operator %s not supported for field %s") % (op, field) + # ) + # if not isinstance(value, datetime.date): + # raise ValidationError( + # _("Date fields must be of type date, not %s") % type(value) + # ) + # value = value.strftime(self._date_format) + # elif field in ("rooms", "id", "reservation_code"): + # if op == "=": + # if not isinstance(value, int): + # raise ValidationError( + # _("Value should be an integer for field %s and operator %s") + # % (field, op) + # ) + # if field == "rooms": + # value = [value] + # elif op == "in": + # if not isinstance(value, (tuple, list)): + # raise ValidationError( + # _( + # "Value should be a list of valued " + # "for field %s and operator %s" + # ) + # % (field, op) + # ) + # else: + # raise NotImplementedError( + # _("Operator %s not suported for field %s") % (op, field) + # ) + # elif field == 'mark': + # if op == "=": + # value = value and 1 or 0 + # elif op == '!=': + # value = not value and 1 or 0 + # else: + # raise NotImplementedError( + # _("Operator %s not supported for field %s") % (op, field) + # ) + # else: + # raise ValidationError(_("Unexpected field %s") % field) + # values[field] = value + # return values diff --git a/connector_pms_wubook/components/binder.py b/connector_pms_wubook/components/binder.py new file mode 100644 index 00000000000..9eaa9b7aaf4 --- /dev/null +++ b/connector_pms_wubook/components/binder.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 ChannelWubookBinder(AbstractComponent): + _name = "channel.wubook.binder" + _inherit = ["channel.binder", "base.channel.wubook.connector"] + + _bind_ids_field = "channel_wubook_bind_ids" diff --git a/connector_pms_wubook/components/core.py b/connector_pms_wubook/components/core.py new file mode 100644 index 00000000000..d832ed479e3 --- /dev/null +++ b/connector_pms_wubook/components/core.py @@ -0,0 +1,13 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class BaseChannelWubookConnector(AbstractComponent): + _name = "base.channel.wubook.connector" + _inherit = "base.channel.connector" + + _collection = "channel.wubook.backend" + + _description = "Base Wubook Channel Connector Component" diff --git a/connector_pms_wubook/components/deleter.py b/connector_pms_wubook/components/deleter.py new file mode 100644 index 00000000000..0057ea9008c --- /dev/null +++ b/connector_pms_wubook/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 ChannelWubookDeleter(AbstractComponent): + _name = "channel.wubook.deleter" + _inherit = ["channel.deleter", "base.channel.wubook.connector"] diff --git a/connector_pms_wubook/components/exporter.py b/connector_pms_wubook/components/exporter.py new file mode 100644 index 00000000000..40b93c03de4 --- /dev/null +++ b/connector_pms_wubook/components/exporter.py @@ -0,0 +1,41 @@ +# 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 ChannelWubookExporter(AbstractComponent): + """ Wubook exporter for Channel """ + + _name = "channel.wubook.exporter" + _inherit = ["channel.exporter", "base.channel.wubook.connector"] + + _default_binding_field = "channel_wubook_bind_ids" + + +class ChannelWubookBatchExporter(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.wubook.batch.exporter" + _inherit = ["channel.batch.exporter", "base.channel.wubook.connector"] + + +class ChannelWubookDirectBatchExporter(AbstractComponent): + """ Import the records directly, without delaying the jobs. """ + + _name = "channel.wubook.direct.batch.exporter" + _inherit = "channel.direct.batch.exporter" + + +class ChannelWubookDelayedBatchExporter(AbstractComponent): + """ Delay import of the records """ + + _name = "channel.wubook.delayed.batch.exporter" + _inherit = "channel.delayed.batch.exporter" diff --git a/connector_pms_wubook/components/importer.py b/connector_pms_wubook/components/importer.py new file mode 100644 index 00000000000..74ad01eebfd --- /dev/null +++ b/connector_pms_wubook/components/importer.py @@ -0,0 +1,39 @@ +# 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 ChannelWubookImporter(AbstractComponent): + """ Wubook importer for Channel """ + + _name = "channel.wubook.importer" + _inherit = ["channel.importer", "base.channel.wubook.connector"] + + +class ChannelWubookBatchImporter(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.wubook.batch.importer" + _inherit = ["channel.batch.importer", "base.channel.wubook.connector"] + + +class ChannelWubookDirectBatchImporter(AbstractComponent): + """ Import the records directly, without delaying the jobs. """ + + _name = "channel.wubook.direct.batch.importer" + _inherit = "channel.direct.batch.importer" + + +class ChannelWubookDelayedBatchImporter(AbstractComponent): + """ Delay import of the records """ + + _name = "channel.wubook.delayed.batch.importer" + _inherit = "channel.delayed.batch.importer" diff --git a/connector_pms_wubook/components/mapper_export.py b/connector_pms_wubook/components/mapper_export.py new file mode 100644 index 00000000000..c2e9bf0e307 --- /dev/null +++ b/connector_pms_wubook/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.wubook.mapper.export" + _inherit = ["channel.mapper.export", "base.channel.wubook.connector"] + + +class ChannelWubookChildMapperExport(AbstractComponent): + _name = "channel.wubook.child.mapper.export" + _inherit = ["channel.child.mapper.export", "base.channel.wubook.connector"] diff --git a/connector_pms_wubook/components/mapper_import.py b/connector_pms_wubook/components/mapper_import.py new file mode 100644 index 00000000000..0b479861853 --- /dev/null +++ b/connector_pms_wubook/components/mapper_import.py @@ -0,0 +1,21 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class ChannelWubookMapperImport(AbstractComponent): + _name = "channel.wubook.mapper.import" + _inherit = ["channel.mapper.import", "base.channel.wubook.connector"] + + # TODO: try to restore this here but solve first the problem + # with child mappers which don't need a backend_id + # @only_create + # @mapping + # def backend_id(self, record): + # return {"backend_id": self.backend_record.id} + + +class ChannelWubookChildMapperImport(AbstractComponent): + _name = "channel.wubook.child.mapper.import" + _inherit = ["channel.child.mapper.import", "base.channel.wubook.connector"] diff --git a/connector_pms_wubook/controllers/__init__.py b/connector_pms_wubook/controllers/__init__.py new file mode 100644 index 00000000000..8383cfad2b0 --- /dev/null +++ b/connector_pms_wubook/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import main diff --git a/connector_pms_wubook/controllers/main.py b/connector_pms_wubook/controllers/main.py new file mode 100644 index 00000000000..a4d3dd39c51 --- /dev/null +++ b/connector_pms_wubook/controllers/main.py @@ -0,0 +1,94 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from odoo import _, http +from odoo.exceptions import ValidationError +from odoo.http import request + + +class WubookPushURL(http.Controller): + # Called when created a reservation in wubook + @http.route( + ["/wubook/push/reservations/"], + type="http", + cors="*", + auth="public", + methods=["POST"], + website=True, + csrf=False, + ) + def wubook_push_reservations(self, security_token, **kwargs): + rcode = kwargs.get("rcode") + lcode = kwargs.get("lcode") + + # Correct Input? + if not lcode or not rcode or not security_token: + raise ValidationError(_("Invalid Input Parameters!")) + + # WuBook Check + if rcode == "2000" and lcode == "1000": + return request.make_response("200 OK", [("Content-Type", "text/plain")]) + + # Get Backend + backend = request.env["channel.backend"].search( + [ + ("security_token", "=", security_token), + ("lcode", "=", lcode), + ] + ) + if not backend: + raise ValidationError(_("Can't found a backend!")) + + request.env["channel.hotel.reservation"].import_reservation(rcode) + + return request.make_response("200 OK", [("Content-Type", "text/plain")]) + + # Called when modify room values (Delay: ~5mins) + @http.route( + ["/wubook/push/rooms/"], + type="http", + cors="*", + auth="public", + methods=["POST"], + website=True, + csrf=False, + ) + def wubook_push_rooms(self, security_token, **kwargs): + lcode = kwargs.get("lcode") + dfrom = kwargs.get("dfrom") + dto = kwargs.get("dto") + + # Correct Input? + if not lcode or not dfrom or not dto: + raise ValidationError(_("Invalid Input Parameters!")) + + # Get Backend + backend = request.env["channel.backend"].search( + [ + ("security_token", "=", security_token), + ("lcode", "=", lcode), + ] + ) + if not backend: + raise ValidationError(_("Can't found a backend!")) + + odoo_dfrom = datetime.strptime(dfrom, DEFAULT_WUBOOK_DATE_FORMAT).strftime( + DEFAULT_SERVER_DATE_FORMAT + ) + odoo_dto = datetime.strptime(dto, DEFAULT_WUBOOK_DATE_FORMAT).strftime( + DEFAULT_SERVER_DATE_FORMAT + ) + + request.env["channel.hotel.room.type.availability"].import_availability( + backend, odoo_dfrom, odoo_dto + ) + request.env[ + "channel.hotel.room.type.restriction.item" + ].import_restriction_values(backend, odoo_dfrom, odoo_dto, False) + request.env["channel.product.pricelist.item"].import_pricelist_values( + backend, odoo_dfrom, odoo_dto, False + ) + + return request.make_response("200 OK", [("Content-Type", "text/plain")]) diff --git a/connector_pms_wubook/data/queue_data.xml b/connector_pms_wubook/data/queue_data.xml new file mode 100644 index 00000000000..d68ee926231 --- /dev/null +++ b/connector_pms_wubook/data/queue_data.xml @@ -0,0 +1,11 @@ + + + + + + wubook + + + + diff --git a/connector_pms_wubook/data/queue_job_function_data.xml b/connector_pms_wubook/data/queue_job_function_data.xml new file mode 100644 index 00000000000..4f078103e3a --- /dev/null +++ b/connector_pms_wubook/data/queue_job_function_data.xml @@ -0,0 +1,80 @@ + + + + + + + import_record + + + + + + + export_record + + + + + + + import_data + + + + + + export_data + + + + + + + + import_record + + + + + diff --git a/connector_pms_wubook/models/__init__.py b/connector_pms_wubook/models/__init__.py new file mode 100644 index 00000000000..6d1eacc23a4 --- /dev/null +++ b/connector_pms_wubook/models/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import channel +from . import pms_room_type +from . import pms_room_type_class +from . import pms_board_service +from . import pms_room_type_board_service +from . import product_pricelist +from . import product_pricelist_item +from . import pms_availability_plan +from . import pms_availability_plan_rule +from . import pms_folio +from . import pms_reservation +from . import pms_reservation_line + +from . import queue_job diff --git a/connector_pms_wubook/models/channel/__init__.py b/connector_pms_wubook/models/channel/__init__.py new file mode 100644 index 00000000000..19e94139d8e --- /dev/null +++ b/connector_pms_wubook/models/channel/__init__.py @@ -0,0 +1,8 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import binding +from . import backend +from . import backend_type +from . import backend_type_board_service +from . import backend_type_room_type_class diff --git a/connector_pms_wubook/models/channel/backend.py b/connector_pms_wubook/models/channel/backend.py new file mode 100644 index 00000000000..91f4a4d020f --- /dev/null +++ b/connector_pms_wubook/models/channel/backend.py @@ -0,0 +1,158 @@ +# 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 UserError + +_logger = logging.getLogger(__name__) + + +class ChannelWubookBackend(models.Model): + _name = "channel.wubook.backend" + _inherit = "connector.backend" + _inherits = {"channel.backend": "parent_id"} + _description = "Channel Wubook PMS Backend" + + parent_id = fields.Many2one( + comodel_name="channel.backend", + string="Parent Channel Backend", + required=True, + ondelete="cascade", + ) + + _sql_constraints = [ + ( + "backend_parent_uniq", + "unique(parent_id)", + "Only one backend child is allowed for each generic backend.", + ), + ] + + # connection data + username = fields.Char("Username", required=True) + password = fields.Char("Password", required=True) + + url = fields.Char( + string="Url", default="https://wired.wubook.net/xrws/", required=True + ) + property_code = fields.Char(string="Property code", required=True) + pkey = fields.Char(string="PKey", required=True) + + # room type + def import_room_types(self): + self = self.with_user(self.user_id) + for rec in self: + rec.env["channel.wubook.pms.room.type"].with_delay().import_data( + backend_record=rec + ) + + def export_room_types(self): + self = self.with_user(self.user_id) + for rec in self: + rec.env["channel.wubook.pms.room.type"].with_delay().export_data( + backend_record=rec + ) + + # room type class + def import_room_type_classes(self): + self = self.with_user(self.user_id) + for rec in self: + rec.env["channel.wubook.pms.room.type.class"].with_delay().import_data( + backend_record=rec + ) + + def export_room_types_classes(self): + self = self.with_user(self.user_id) + for rec in self: + rec.env["channel.wubook.pms.room.type.class"].with_delay().export_data( + backend_record=rec + ) + + # pricelist + pricelist_date_from = fields.Date("Pricelist Date From") + pricelist_date_to = fields.Date("Pricelist Date To") + pricelist_ids = fields.Many2many( + comodel_name="product.pricelist", + relation="wubook_backend_pricelist_rel", + column1="backend_id", + column2="pricelist_id", + domain=[("pricelist_type", "=", "daily")], + ) + # TODO: add logic to control this and filter the rooms by the current property + pricelist_room_type_ids = fields.Many2many( + comodel_name="pms.room.type", + relation="wubook_backend_pricelist_room_type_rel", + column1="backend_id", + column2="room_type_id", + ) + + def import_pricelists(self): + self = self.with_user(self.user_id) + for rec in self: + if rec.pricelist_date_to < rec.pricelist_date_from: + raise UserError(_("Date to must be greater than date from")) + rec.env["channel.wubook.product.pricelist"].with_delay().import_data( + rec, + rec.pricelist_date_from, + rec.pricelist_date_to, + rec.pricelist_ids, + rec.pricelist_room_type_ids, + ) + + def export_pricelists(self): + self = self.with_user(self.user_id) + for rec in self: + rec.env["channel.wubook.product.pricelist"].with_delay().export_data( + backend_record=rec + ) + + # availability plan + plan_date_from = fields.Date("Availability Plan Date From") + plan_date_to = fields.Date("Availability Plan Date To") + # TODO: add logic to control this and filter the rooms by the current property + plan_room_type_ids = fields.Many2many( + comodel_name="pms.room.type", + relation="wubook_backend_plan_room_type_rel", + column1="backend_id", + column2="room_type_id", + ) + + def import_availability_plans(self): + self = self.with_user(self.user_id) + for rec in self: + if rec.plan_date_to < rec.plan_date_from: + raise UserError(_("Date to must be greater than date from")) + rec.env["channel.wubook.pms.availability.plan"].with_delay().import_data( + rec, + rec.plan_date_from, + rec.plan_date_to, + rec.plan_room_type_ids, + ) + + def export_availability_plans(self): + self = self.with_user(self.user_id) + for rec in self: + rec.env["channel.wubook.pms.availability.plan"].with_delay().export_data( + backend_record=rec + ) + + # folio + folio_date_arrival_from = fields.Date(string="Arrival Date From") + folio_date_arrival_to = fields.Date(string="Arrival Date To") + folio_mark = fields.Boolean(string="Mark") + + def import_folios(self): + self = self.with_user(self.user_id) + for rec in self: + if rec.folio_date_arrival_to < rec.folio_date_arrival_from: + raise UserError(_("Date to must be greater than date from")) + # rec.env["channel.wubook.pms.folio"].import_data( + rec.env["channel.wubook.pms.folio"].with_context( + test_queue_job_no_delay=True + ).with_delay().import_data( + rec, + rec.folio_date_arrival_from, + rec.folio_date_arrival_to, + rec.folio_mark, + ) diff --git a/connector_pms_wubook/models/channel/backend_type.py b/connector_pms_wubook/models/channel/backend_type.py new file mode 100644 index 00000000000..5f92c69eb2d --- /dev/null +++ b/connector_pms_wubook/models/channel/backend_type.py @@ -0,0 +1,54 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class ChannelBackend(models.Model): + _inherit = "channel.backend.type" + + @api.model + def _get_channel_backend_type_model_names(self): + res = super(ChannelBackend, self)._get_channel_backend_type_model_names() + res.append("channel.wubook.backend.type") + return res + + +class ChannelWubookBackendType(models.Model): + _name = "channel.wubook.backend.type" + _inherits = {"channel.backend.type": "parent_id"} + _description = "Channel Wubook PMS Backend Type" + + _main_model = "channel.wubook.backend" + + parent_id = fields.Many2one( + comodel_name="channel.backend.type", + string="Parent Channel Backend Type", + required=True, + ondelete="cascade", + ) + + _sql_constraints = [ + ( + "backend_parent_uniq", + "unique(parent_id)", + "Only one backend child is allowed for each generic backend.", + ), + ] + + # room type class map + room_type_class_ids = fields.One2many( + string="Room type classes", + comodel_name="channel.wubook.backend.type.room.type.class", + inverse_name="backend_type_id", + ) + + # board service map + board_service_ids = fields.One2many( + string="Board services", + comodel_name="channel.wubook.backend.type.board.service", + inverse_name="backend_type_id", + ) diff --git a/connector_pms_wubook/models/channel/backend_type_board_service.py b/connector_pms_wubook/models/channel/backend_type_board_service.py new file mode 100644 index 00000000000..f6547bbc2eb --- /dev/null +++ b/connector_pms_wubook/models/channel/backend_type_board_service.py @@ -0,0 +1,52 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class ChannelWubookBackendTypeBoardService(models.Model): + _name = "channel.wubook.backend.type.board.service" + _description = "Channel Wubook PMS Backend Type Board services Map" + + backend_type_id = fields.Many2one( + comodel_name="channel.wubook.backend.type", + required=True, + ondelete="cascade", + ) + + wubook_board_service = fields.Selection( + string="Wubook Board Service", + required=True, + selection=[ + # ("nb", "No Board"), # no board means without any board service + ("bb", "Breakfast"), + ("hb", "Half Board"), + ("fb", "Full Board"), + ("ai", "All Inclusive"), + ], + ) + + board_service_shortname = fields.Char( + string="Wubook Board Service Shortname", + ) + + _sql_constraints = [ + ( + "uniq", + "unique(backend_type_id,wubook_board_service,board_service_shortname)", + "Board Service and Shortname already used in another map line", + ), + ( + "board_service_uniq", + "unique(backend_type_id,wubook_board_service)", + "Board Service already used in another map line", + ), + ( + "board_service_shortname_uniq", + "unique(backend_type_id,board_service_shortname)", + "Shortname already used in another map line", + ), + ] diff --git a/connector_pms_wubook/models/channel/backend_type_room_type_class.py b/connector_pms_wubook/models/channel/backend_type_room_type_class.py new file mode 100644 index 00000000000..b2f46f91061 --- /dev/null +++ b/connector_pms_wubook/models/channel/backend_type_room_type_class.py @@ -0,0 +1,55 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class ChannelWubookBackendTypeRoomTypeClass(models.Model): + _name = "channel.wubook.backend.type.room.type.class" + _description = "Channel Wubook PMS Backend Type Room Type Class Map" + + backend_type_id = fields.Many2one( + comodel_name="channel.wubook.backend.type", + required=True, + ondelete="cascade", + ) + + wubook_room_type = fields.Selection( + string="Wubook Room Type", + required=True, + selection=[ + ("1", "Room"), + ("2", "Apartment"), + ("3", "Bed"), + ("4", "Unit"), + ("5", "Bungalow"), + ("6", "Tent"), + ("7", "Villa"), + ("8", "Chalet"), + ("9", "RV park"), + ], + ) + room_type_shortname = fields.Char( + string="Room Type Shortname", + ) + + _sql_constraints = [ + ( + "uniq", + "unique(backend_type_id,wubook_room_type,room_type_shortname)", + "Room Type and Shortname already used in another map line", + ), + ( + "room_type_uniq", + "unique(backend_type_id,wubook_room_type)", + "Room Type already used in another map line", + ), + ( + "room_type_shortname_uniq", + "unique(backend_type_id,room_type_shortname)", + "Shortname already used in another map line", + ), + ] diff --git a/connector_pms_wubook/models/channel/binding.py b/connector_pms_wubook/models/channel/binding.py new file mode 100644 index 00000000000..81e0b527de8 --- /dev/null +++ b/connector_pms_wubook/models/channel/binding.py @@ -0,0 +1,18 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ChannelWubookBinding(models.AbstractModel): + _name = "channel.wubook.binding" + _inherit = "channel.binding" + + # binding fields + backend_id = fields.Many2one( + comodel_name="channel.wubook.backend", + string="Wubook Backend", + required=True, + readonly=True, + ondelete="restrict", + ) diff --git a/connector_pms_wubook/models/pms_availability_plan/__init__.py b/connector_pms_wubook/models/pms_availability_plan/__init__.py new file mode 100644 index 00000000000..7c7a93d7b16 --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan/__init__.py @@ -0,0 +1,12 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from . import adapter +from . import binder +from . import binding +from . import exporter +from . import importer +from . import mapper_export +from . import mapper_import +from . import pms_availability_plan diff --git a/connector_pms_wubook/models/pms_availability_plan/adapter.py b/connector_pms_wubook/models/pms_availability_plan/adapter.py new file mode 100644 index 00000000000..d78b3ea6ea8 --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan/adapter.py @@ -0,0 +1,212 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime + +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import Component +from odoo.addons.connector_pms.components.adapter import ChannelAdapterError + +RESTRICTION_FIELDS = [ + "closed_arrival", + "closed", + "min_stay", + "closed_departure", + "max_stay", + "min_stay_arrival", + # "max_stay_arrival" # API does not return this eventhough is in the GUI +] + +AVAILABILITY_FIELDS = ["avail", "no_ota"] + + +class ChannelWubookPmsAvailabilityPlanAdapter(Component): + _name = "channel.wubook.pms.availability.plan" + _inherit = "channel.wubook.adapter" + + _apply_on = "channel.wubook.pms.availability.plan" + + # CRUD + # pylint: disable=W8106 + def create(self, values): + # https://tdocs.wubook.net/wired/rstrs.html#rplan_add_rplan + + # plan values + if "compact" not in values: + values["compact"] = 0 + if values["compact"] != 0: + raise ChannelAdapterError(_("Compact type plan is currently not supported")) + + params = self._prepare_parameters( + {k: values[k] for k in values if k in {"name", "compact"}}, + ["name", "compact"], + ) + _id = self._exec("rplan_add_rplan", *params) + + # rule item values + items = values.get("items") + if items: + try: + self._write_items(_id, items) + except ChannelAdapterError: + self.delete(_id) + raise + + return _id + + def read(self, _id): + # https://tdocs.wubook.net/wired/rstrs.html#rplan_rplans + # https://tdocs.wubook.net/wired/rstrs.html#rplan_get_rplan_values + values = self.search_read([("id", "=", _id)]) + if not values: + return False + if len(values) != 1: + raise ChannelAdapterError(_("Received more than one plan %s") % (values,)) + return values[0] + + def search_read(self, domain): + # self._check_supported_domain_format(domain) + # https://tdocs.wubook.net/wired/rstrs.html#rplan_rplans + # https://tdocs.wubook.net/wired/rstrs.html#rplan_get_rplan_values + # https://tdocs.wubook.net/wired/avail.html#fetch_rooms_values + all_plans = self._exec("rplan_rplans") + real_domain, common_domain = self._extract_domain_clauses( + domain, ["date", "id_room"] + ) + room_domain, real_domain = self._extract_domain_clauses( + real_domain, ["id_room"] + ) + base_plans = self._filter(all_plans, common_domain) + if real_domain: + for base_chunk_plan in self.chunks(base_plans, 6): + # get plan values + kw_base_params = self._domain_to_normalized_dict(real_domain, "date") + kw_params = { + "rpids": [x["id"] for x in base_chunk_plan], + **kw_base_params, + } + params = self._prepare_parameters( + kw_params, ["date_from", "date_to"], ["rpids"] + ) + plans_values = self._exec("rplan_get_rplan_values", *params) + print("----------", plans_values) + # get availability + rooms = set() + for plan_rooms in plans_values.values(): + rooms |= {int(x) for x in plan_rooms.keys()} + kw_params = {"rooms": list(rooms), **kw_base_params} + params = self._prepare_parameters( + kw_params, ["date_from", "date_to"], ["rooms"] + ) + avail_values = self._exec("fetch_rooms_values", *params) + date_from = datetime.datetime.strptime( + kw_params["date_from"], self._date_format + ).date() + + for plan in base_chunk_plan: + plan["items"] = [] + for room_id, room in plans_values[str(plan["id"])].items(): + for day in range(len(room)): + plan["items"].append( + { + **{ + x: room[day][x] + for x in RESTRICTION_FIELDS + ["id_room"] + }, + **{ + x: avail_values[room_id][day][x] + for x in AVAILABILITY_FIELDS + }, + "date": date_from + datetime.timedelta(days=day), + } + ) + plan["items"] = self._filter(plan["items"], room_domain) + return base_plans + + def search(self, domain): + # https://tdocs.wubook.net/wired/rstrs.html#rplan_rplans + # https://tdocs.wubook.net/wired/rstrs.html#rplan_get_rplan_values + values = self.search_read(domain) + ids = [x[self._id] for x in values] + return ids + + # pylint: disable=W8106 + def write(self, _id, values): + # https://tdocs.wubook.net/wired/rstrs.html#rplan_rename_rplan + # https://tdocs.wubook.net/wired/rstrs.html#rplan_get_rplan_values + + # plan values + if "name" in values: + params = self._prepare_parameters( + {"id": _id, **{k: values[k] for k in values if k in {"name"}}}, + ["id", "name"], + ) + self._exec("rplan_rename_rplan", *params) + + # rule item values + items = values.get("items") + if items: + self._write_items(_id, items) + + def delete(self, _id): + # https://tdocs.wubook.net/wired/rstrs.html#rplan_del_rplan + params = self._prepare_parameters( + {"id": _id}, + ["id"], + ) + self._exec("rplan_del_rplan", *params) + + # aux + def _write_items(self, _id, items): + dates = {x["date"] for x in items} + dfrom, dto = min(dates), max(dates) + items_by_room = {} + for room in items: + if not isinstance(room["date"], datetime.date): + raise ValidationError( + _("Date fields must be of type date, not %s") % type(room["date"]) + ) + items_by_room.setdefault(room["id_room"], {}) + if room["date"] in items_by_room[room["id_room"]]: + raise ValidationError(_("The rooms exists twice with the same date")) + items_by_room[room["id_room"]][room["date"]] = room + + plans, avail = {}, {} + for room_id, room_by_date in items_by_room.items(): + for i in range((dto - dfrom).days + 1): + date = dfrom + datetime.timedelta(days=i) + room = room_by_date.get(date, {}) + plans.setdefault(str(room_id), []).append( + {x: room[x] for x in room if x in RESTRICTION_FIELDS} + ) + avail.setdefault(room_id, []).append( + {x: room[x] for x in room if x in AVAILABILITY_FIELDS} + ) + + if plans: + params = self._prepare_parameters( + { + "id": _id, + "dfrom": dfrom.strftime(self._date_format), + "values": plans, + }, + ["id", "dfrom", "values"], + ) + self._exec("rplan_update_rplan_values", *params) + + if avail: + params = self._prepare_parameters( + { + "dfrom": dfrom.strftime(self._date_format), + "rooms": [ + { + "id": _id, + "days": list(days), + } + for _id, days in avail.items() + ], + }, + ["dfrom", "rooms"], + ) + self._exec("update_avail", *params) diff --git a/connector_pms_wubook/models/pms_availability_plan/binder.py b/connector_pms_wubook/models/pms_availability_plan/binder.py new file mode 100644 index 00000000000..067f6fb0b0b --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan/binder.py @@ -0,0 +1,20 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsAvailabilityPlanBinder(Component): + _name = "channel.wubook.pms.availability.plan.binder" + _inherit = "channel.wubook.binder" + + _apply_on = "channel.wubook.pms.availability.plan" + + _internal_alt_id = "name" + _external_alt_id = "name" + + # def _get_internal_record_alt(self, model_name, values): + # pass + + # TODO: find availability plan by name to link to backend when there's + # no binding diff --git a/connector_pms_wubook/models/pms_availability_plan/binding.py b/connector_pms_wubook/models/pms_availability_plan/binding.py new file mode 100644 index 00000000000..a3a28ff0b2e --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan/binding.py @@ -0,0 +1,83 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class ChannelWubookPmsAvailabilityPlanBinding(models.Model): + _name = "channel.wubook.pms.availability.plan" + _inherit = "channel.wubook.binding" + _inherits = {"pms.availability.plan": "odoo_id"} + + # binding fields + odoo_id = fields.Many2one( + comodel_name="pms.availability.plan", + string="Odoo ID", + required=True, + ondelete="cascade", + ) + + @api.model + def import_data( + self, + backend_id, + date_from, + date_to, + room_type_ids, + delayed=False, + ): + """ Prepare the batch import of Availability Plans from Channel """ + domain = [] + if date_from and date_to: + domain += [ + ("date", ">=", date_from), + ("date", "<=", date_to), + ] + # TODO: duplicated code, unify + if room_type_ids: + with backend_id.work_on("channel.wubook.pms.room.type") as work: + binder = work.component(usage="binder") + external_ids = [] + for rt in room_type_ids: + binding = binder.wrap_record(rt) + if not binding or not binding.external_id: + raise NotImplementedError( + _( + "The Room type %s has no binding. Import of Odoo records " + "without binding is not supported yet" + ) + % rt.name + ) + external_ids.append(binding.external_id) + domain.append(("id_room", "in", external_ids)) + return self.import_batch( + backend_record=backend_id, domain=domain, delayed=delayed + ) + + @api.model + def export_data(self, backend_record=None): + """ Prepare the batch export of Availability Plan to Channel """ + return self.export_batch( + backend_record=backend_record, + domain=[ + ("pms_property_ids", "in", backend_record.pms_property_id.ids), + # ("name", "=", "test101"), + ], + ) + + def resync_import(self): + for record in self: + items = record.rule_ids.filtered( + lambda x: x.pms_property_id == self.backend_id.pms_property_id + ) + if items: + date_from = min(items.mapped("date")) + date_to = max(items.mapped("date")) + room_types = items.mapped("room_type_id") + record.import_data( + self.backend_id, + date_from, + date_to, + room_types, + delayed=False, + ) diff --git a/connector_pms_wubook/models/pms_availability_plan/exporter.py b/connector_pms_wubook/models/pms_availability_plan/exporter.py new file mode 100644 index 00000000000..ebb1f99bbb4 --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan/exporter.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 Component + + +class ChannelWubookPmsAvailabilityPlanDelayedBatchExporter(Component): + _name = "channel.wubook.pms.availability.plan.delayed.batch.exporter" + _inherit = "channel.wubook.delayed.batch.exporter" + + _apply_on = "channel.wubook.pms.availability.plan" + + +class ChannelWubookPmsAvailabilityPlanDirectBatchExporter(Component): + _name = "channel.wubook.pms.availability.plan.direct.batch.exporter" + _inherit = "channel.wubook.direct.batch.exporter" + + _apply_on = "channel.wubook.pms.availability.plan" + + +class ChannelWubookPmsAvailabilityPlanExporter(Component): + _name = "channel.wubook.pms.availability.plan.exporter" + _inherit = "channel.wubook.exporter" + + _apply_on = "channel.wubook.pms.availability.plan" + + def _export_dependencies(self): + for room_type in self.binding.rule_ids.mapped("room_type_id"): + self._export_dependency(room_type, "channel.wubook.pms.room.type") diff --git a/connector_pms_wubook/models/pms_availability_plan/importer.py b/connector_pms_wubook/models/pms_availability_plan/importer.py new file mode 100644 index 00000000000..1f62037a471 --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan/importer.py @@ -0,0 +1,31 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsAvailabilityPlanDelayedBatchImporter(Component): + _name = "channel.wubook.pms.availability.plan.delayed.batch.importer" + _inherit = "channel.wubook.delayed.batch.importer" + + _apply_on = "channel.wubook.pms.availability.plan" + + +class ChannelWubookPmsAvailabilityPlanDirectBatchImporter(Component): + _name = "channel.wubook.pms.availability.plan.direct.batch.importer" + _inherit = "channel.wubook.direct.batch.importer" + + _apply_on = "channel.wubook.pms.availability.plan" + + +class ChannelWubookPmsAvailabilityPlanImporter(Component): + _name = "channel.wubook.pms.availability.plan.importer" + _inherit = "channel.wubook.importer" + + _apply_on = "channel.wubook.pms.availability.plan" + + def _import_dependencies(self, external_data, external_fields): + # if not external_fields or 'id_room' in external_fields: + rids = {x["id_room"] for x in external_data.get("items", [])} + self._import_dependency(rids, "channel.wubook.pms.room.type") diff --git a/connector_pms_wubook/models/pms_availability_plan/mapper_export.py b/connector_pms_wubook/models/pms_availability_plan/mapper_export.py new file mode 100644 index 00000000000..4075886e4ef --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan/mapper_export.py @@ -0,0 +1,29 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping + + +class ChannelWubookPmsAvailabilityPlanMapperExport(Component): + _name = "channel.wubook.pms.availability.plan.mapper.export" + _inherit = "channel.wubook.mapper.export" + + _apply_on = "channel.wubook.pms.availability.plan" + + direct = [ + ("name", "name"), + ] + + children = [("rule_ids", "items", "channel.wubook.pms.availability.plan.rule")] + + @mapping + def name(self, record): + return {"name": record["name"]} + + +class ChannelWubookPmsAvailabilityPlanChildMapperExport(Component): + _name = "channel.wubook.pms.availability.plan.child.mapper.export" + _inherit = "channel.wubook.child.mapper.export" + _apply_on = "channel.wubook.pms.availability.plan.rule" diff --git a/connector_pms_wubook/models/pms_availability_plan/mapper_import.py b/connector_pms_wubook/models/pms_availability_plan/mapper_import.py new file mode 100644 index 00000000000..32f549b24fe --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan/mapper_import.py @@ -0,0 +1,77 @@ +# 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 Component +from odoo.addons.connector.components.mapper import mapping, only_create + + +class ChannelWubookPmsAvailabilityPlanMapperImport(Component): + _name = "channel.wubook.pms.availability.plan.mapper.import" + _inherit = "channel.wubook.mapper.import" + + _apply_on = "channel.wubook.pms.availability.plan" + + direct = [ + ("name", "name"), + ] + + children = [("items", "rule_ids", "channel.wubook.pms.availability.plan.rule")] + + @only_create + @mapping + def backend_id(self, record): + return {"backend_id": self.backend_record.id} + + @mapping + def property_ids(self, record): + return {"pms_property_ids": [(6, 0, [self.backend_record.pms_property_id.id])]} + + +class ChannelWubookPmsAvailabilityPlanChildMapperImport(Component): + _name = "channel.wubook.pms.availability.plan.child.mapper.import" + _inherit = "channel.wubook.child.mapper.import" + _apply_on = "channel.wubook.pms.availability.plan.rule" + + def get_item_values(self, map_record, to_attr, options): + values = super().get_item_values(map_record, to_attr, options) + binding = options.get("binding") + if binding: + item_ids = binding.rule_ids.filtered( + lambda x: all( + [ + x.date == values["date"], + x.room_type_id.id == values["room_type_id"], + x.pms_property_id.id == values["pms_property_id"], + ] + ) + ) + if item_ids: + if len(item_ids) > 1: + raise ValidationError( + _( + "Found two pricelist items with same properties %s. " + "Please remove one of them" + ) + % values + ) + values["id"] = item_ids.id + + return values + + def format_items(self, items_values): + ops = [] + items_values = sorted( + items_values, key=lambda x: (x["room_type_id"], x["date"]), reverse=True + ) + # TODO: the next code is always the same, put it on a common parent + for values in items_values: + _id = values.pop("id", None) + if _id: + ops.append((1, _id, values)) + else: + ops.append((0, 0, values)) + + return ops diff --git a/connector_pms_wubook/models/pms_availability_plan/pms_availability_plan.py b/connector_pms_wubook/models/pms_availability_plan/pms_availability_plan.py new file mode 100644 index 00000000000..0758b77ba93 --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan/pms_availability_plan.py @@ -0,0 +1,14 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PmsAvailabilityPlan(models.Model): + _inherit = "pms.availability.plan" + + channel_wubook_bind_ids = fields.One2many( + comodel_name="channel.wubook.pms.availability.plan", + inverse_name="odoo_id", + string="Channel Wubook PMS Bindings", + ) diff --git a/connector_pms_wubook/models/pms_availability_plan_rule/__init__.py b/connector_pms_wubook/models/pms_availability_plan_rule/__init__.py new file mode 100644 index 00000000000..14580418472 --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan_rule/__init__.py @@ -0,0 +1,8 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from . import binding +from . import mapper_export +from . import mapper_import +from . import pms_availability_plan_rule diff --git a/connector_pms_wubook/models/pms_availability_plan_rule/binding.py b/connector_pms_wubook/models/pms_availability_plan_rule/binding.py new file mode 100644 index 00000000000..0034384eed5 --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan_rule/binding.py @@ -0,0 +1,17 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ChannelWubookPmsAvailabilityPlanRuleBinding(models.Model): + _name = "channel.wubook.pms.availability.plan.rule" + _inherit = "channel.wubook.binding" + _inherits = {"pms.availability.plan.rule": "odoo_id"} + + odoo_id = fields.Many2one( + comodel_name="pms.availability.plan.rule", + string="Odoo ID", + required=True, + ondelete="cascade", + ) diff --git a/connector_pms_wubook/models/pms_availability_plan_rule/mapper_export.py b/connector_pms_wubook/models/pms_availability_plan_rule/mapper_export.py new file mode 100644 index 00000000000..e7fa5fd3793 --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan_rule/mapper_export.py @@ -0,0 +1,43 @@ +# 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 Component +from odoo.addons.connector.components.mapper import mapping + + +class ChannelWubookPmsAvailabilityPlanRuleMapperExport(Component): + _name = "channel.wubook.pms.availability.plan.rule.mapper.export" + _inherit = "channel.wubook.mapper.export" + + _apply_on = "channel.wubook.pms.availability.plan.rule" + + direct = [ + ("date", "date"), + ("no_ota", "no_ota"), + ("min_stay", "min_stay"), + ("max_stay", "max_stay"), + ("min_stay_arrival", "min_stay_arrival"), + ("closed", "closed"), + ("closed_arrival", "closed_arrival"), + ("closed_departure", "closed_departure"), + ] + + @mapping + def room_type(self, record): + room_type = record["room_type_id"] + rt_binder = self.binder_for("channel.wubook.pms.room.type") + external_id = rt_binder.to_external(room_type, wrap=True) + if not external_id: + raise ValidationError( + _( + "External record of Room Type id [%s] %s does not exists. " + "It should be exported in _export_dependencies" + ) + % (room_type.default_code, room_type.name) + ) + return { + "room_type_id": room_type.id, + } diff --git a/connector_pms_wubook/models/pms_availability_plan_rule/mapper_import.py b/connector_pms_wubook/models/pms_availability_plan_rule/mapper_import.py new file mode 100644 index 00000000000..870380dbfa4 --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan_rule/mapper_import.py @@ -0,0 +1,46 @@ +# 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 Component +from odoo.addons.connector.components.mapper import mapping + + +class ChannelWubookPmsAvailabilityPlanRuleMapperImport(Component): + _name = "channel.wubook.pms.availability.plan.rule.mapper.import" + _inherit = "channel.wubook.mapper.import" + + _apply_on = "channel.wubook.pms.availability.plan.rule" + + direct = [ + ("date", "date"), + ("no_ota", "no_ota"), + ("min_stay", "min_stay"), + ("max_stay", "max_stay"), + ("min_stay_arrival", "min_stay_arrival"), + ("closed", "closed"), + ("closed_arrival", "closed_arrival"), + ("closed_departure", "closed_departure"), + ] + + @mapping + def property_ids(self, record): + return {"pms_property_id": self.backend_record.pms_property_id.id} + + @mapping + def room(self, record): + rt_binder = self.binder_for("channel.wubook.pms.room.type") + room_type = rt_binder.to_internal(record["id_room"], unwrap=True) + if not room_type: + raise ValidationError( + _( + "External record with id %i not exists. " + "It should be imported in _import_dependencies" + ) + % record["rid"] + ) + return { + "room_type_id": room_type.id, + } diff --git a/connector_pms_wubook/models/pms_availability_plan_rule/pms_availability_plan_rule.py b/connector_pms_wubook/models/pms_availability_plan_rule/pms_availability_plan_rule.py new file mode 100644 index 00000000000..95c48503dae --- /dev/null +++ b/connector_pms_wubook/models/pms_availability_plan_rule/pms_availability_plan_rule.py @@ -0,0 +1,16 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PmsRoomTypeAvailabilityRule(models.Model): + _inherit = "pms.availability.plan.rule" + + no_ota = fields.Boolean( + string="No OTA", + default=False, + help="Set zero availability to the connected OTAs " + "even when the availability is positive," + "except to the Online Reception (booking engine)", + ) diff --git a/connector_pms_wubook/models/pms_board_service/__init__.py b/connector_pms_wubook/models/pms_board_service/__init__.py new file mode 100644 index 00000000000..2ba57792f71 --- /dev/null +++ b/connector_pms_wubook/models/pms_board_service/__init__.py @@ -0,0 +1,12 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from . import adapter +from . import binder +from . import binding +from . import exporter +from . import importer +from . import mapper_export +from . import mapper_import +from . import pms_board_service diff --git a/connector_pms_wubook/models/pms_board_service/adapter.py b/connector_pms_wubook/models/pms_board_service/adapter.py new file mode 100644 index 00000000000..7309988cf07 --- /dev/null +++ b/connector_pms_wubook/models/pms_board_service/adapter.py @@ -0,0 +1,77 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _ + +from odoo.addons.component.core import Component +from odoo.addons.connector_pms.components.adapter import ChannelAdapterError + + +class ChannelWubookPmsBoardServiceAdapter(Component): + _name = "channel.wubook.pms.board.service.adapter" + _inherit = "channel.wubook.adapter" + + _apply_on = "channel.wubook.pms.board.service" + + # CRUD + # pylint: disable=W8106 + def create(self, values): + raise ChannelAdapterError( + _( + "Create operation is not supported on Board Service by Wubook. Values: %s. " + "Probably the cause is a wrong mapping of Board Services on Wubook backend type" + % (values,) + ) + ) + + def read(self, _id): + values = self.search_read([("id", "=", _id)]) + if not values: + raise ChannelAdapterError(_("No Board Service found with id '%s'") % _id) + if len(values) != 1: + raise ChannelAdapterError( + _("Received more than one board service %s") % (values,) + ) + return values[0] + + def search_read(self, domain): + values = self._gen_values() + return self._filter(values, domain) + + def search(self, domain): + values = self.search_read(domain) + ids = [x[self._id] for x in values] + return ids + + # pylint: disable=W8106 + def write(self, _id, values): + raise ChannelAdapterError( + _( + "Write operation is not supported on Board Service by Wubook. Id: %i, Values: %s. " + "Probably the cause is a wrong mapping of Board Services on Wubook backend type" + % (_id, values) + ) + ) + + def delete(self, _id): + raise ChannelAdapterError( + _( + "Delete operation is not supported on Board Service by Wubook. Id: %i" + % (_id,) + ) + ) + + def _gen_values(self): + backend_type = self.backend_record.backend_type_id.child_id + names = dict( + backend_type.board_service_ids.fields_get( + ["wubook_board_service"], ["selection"] + )["wubook_board_service"]["selection"] + ) + return [ + { + "id": x.wubook_board_service, + "name": names[x.wubook_board_service], + "shortname": x.board_service_shortname, + } + for x in backend_type.board_service_ids + ] diff --git a/connector_pms_wubook/models/pms_board_service/binder.py b/connector_pms_wubook/models/pms_board_service/binder.py new file mode 100644 index 00000000000..2fa79f16ee0 --- /dev/null +++ b/connector_pms_wubook/models/pms_board_service/binder.py @@ -0,0 +1,49 @@ +# 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 Component + + +class ChannelWubookPmsBoardServiceBinder(Component): + _name = "channel.wubook.pms.board.service.binder" + _inherit = "channel.wubook.binder" + + _apply_on = "channel.wubook.pms.board.service" + + _internal_alt_id = ("default_code", "pms_property_ids") + _external_alt_id = "shortname" + + def _get_internal_record_alt(self, model_name, values): + pms_property_id = values["pms_property_ids"][0][1] + records = self.env[model_name].search( + [ + "&", + ("default_code", "=", values["default_code"]), + "|", + ("pms_property_ids", "in", pms_property_id), + ("pms_property_ids", "=", False), + ] + ) + + res, res_priority = self.env[model_name], -1 + for rec in records: + priority = ( + rec.pms_property_ids + and pms_property_id in rec.pms_property_ids.ids + and 1 + ) or 0 + if priority > res_priority: + res, res_priority = rec, priority + elif priority == res_priority: + raise ValidationError( + _( + "Integrity error: There's more than one room type " + "with same code and properties" + ) + ) + + return res + + # TODO: almost identical code as on room type and room class -> Unify diff --git a/connector_pms_wubook/models/pms_board_service/binding.py b/connector_pms_wubook/models/pms_board_service/binding.py new file mode 100644 index 00000000000..e99823070eb --- /dev/null +++ b/connector_pms_wubook/models/pms_board_service/binding.py @@ -0,0 +1,23 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ChannelWubookPmsBoardServiceBinding(models.Model): + _name = "channel.wubook.pms.board.service" + _inherit = "channel.wubook.binding" + _inherits = {"pms.board.service": "odoo_id"} + + # override default Integer external ID + external_id = fields.Char( + string="External ID", + ) + + # binding fields + odoo_id = fields.Many2one( + comodel_name="pms.board.service", + string="Odoo ID", + required=True, + ondelete="cascade", + ) diff --git a/connector_pms_wubook/models/pms_board_service/exporter.py b/connector_pms_wubook/models/pms_board_service/exporter.py new file mode 100644 index 00000000000..d1b65c2fe50 --- /dev/null +++ b/connector_pms_wubook/models/pms_board_service/exporter.py @@ -0,0 +1,26 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsBoardServiceDelayedBatchExporter(Component): + _name = "channel.wubook.pms.board.service.delayed.batch.exporter" + _inherit = "channel.wubook.delayed.batch.exporter" + + _apply_on = "channel.wubook.pms.board.service" + + +class ChannelWubookPmsBoardServiceDirectBatchExporter(Component): + _name = "channel.wubook.pms.board.service.direct.batch.exporter" + _inherit = "channel.wubook.direct.batch.exporter" + + _apply_on = "channel.wubook.pms.board.service" + + +class ChannelWubookPmsBoardServiceExporter(Component): + _name = "channel.wubook.pms.board.service.exporter" + _inherit = "channel.wubook.exporter" + + _apply_on = "channel.wubook.pms.board.service" diff --git a/connector_pms_wubook/models/pms_board_service/importer.py b/connector_pms_wubook/models/pms_board_service/importer.py new file mode 100644 index 00000000000..3e73e71b3fc --- /dev/null +++ b/connector_pms_wubook/models/pms_board_service/importer.py @@ -0,0 +1,26 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsBoardServiceDelayedBatchImporter(Component): + _name = "channel.wubook.pms.board.service.delayed.batch.importer" + _inherit = "channel.wubook.delayed.batch.importer" + + _apply_on = "channel.wubook.pms.board.service" + + +class ChannelWubookPmsBoardServiceDirectBatchImporter(Component): + _name = "channel.wubook.pms.board.service.direct.batch.importer" + _inherit = "channel.wubook.direct.batch.importer" + + _apply_on = "channel.wubook.pms.board.service" + + +class ChannelWubookPmsBoardServiceImporter(Component): + _name = "channel.wubook.pms.board.service.importer" + _inherit = "channel.wubook.importer" + + _apply_on = "channel.wubook.pms.board.service" diff --git a/connector_pms_wubook/models/pms_board_service/mapper_export.py b/connector_pms_wubook/models/pms_board_service/mapper_export.py new file mode 100644 index 00000000000..db10667a337 --- /dev/null +++ b/connector_pms_wubook/models/pms_board_service/mapper_export.py @@ -0,0 +1,17 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsBoardServiceMapperExport(Component): + _name = "channel.wubook.pms.board.service.mapper.export" + _inherit = "channel.wubook.mapper.export" + + _apply_on = "channel.wubook.pms.board.service" + + direct = [ + ("name", "name"), + ("default_code", "shortname"), + ] diff --git a/connector_pms_wubook/models/pms_board_service/mapper_import.py b/connector_pms_wubook/models/pms_board_service/mapper_import.py new file mode 100644 index 00000000000..17767daa48b --- /dev/null +++ b/connector_pms_wubook/models/pms_board_service/mapper_import.py @@ -0,0 +1,32 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping, only_create + + +class ChannelWubookPmsBoardServiceMapperImport(Component): + _name = "channel.wubook.pms.board.service.mapper.import" + _inherit = "channel.wubook.mapper.import" + + _apply_on = "channel.wubook.pms.board.service" + + direct = [ + ("name", "name"), + ("shortname", "default_code"), + ] + + @only_create + @mapping + def backend_id(self, record): + return {"backend_id": self.backend_record.id} + + @mapping + def property_ids(self, record): + binding = self.options.get("binding") + has_pms_properties = binding and bool(binding.pms_property_ids) + if self.options.for_create or has_pms_properties: + return { + "pms_property_ids": [(4, self.backend_record.pms_property_id.id, 0)] + } diff --git a/connector_pms_wubook/models/pms_board_service/pms_board_service.py b/connector_pms_wubook/models/pms_board_service/pms_board_service.py new file mode 100644 index 00000000000..3f2b1582e87 --- /dev/null +++ b/connector_pms_wubook/models/pms_board_service/pms_board_service.py @@ -0,0 +1,14 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PmsBoardService(models.Model): + _inherit = "pms.board.service" + + channel_wubook_bind_ids = fields.One2many( + comodel_name="channel.wubook.pms.board.service", + inverse_name="odoo_id", + string="Channel Wubook PMS Bindings", + ) diff --git a/connector_pms_wubook/models/pms_folio/__init__.py b/connector_pms_wubook/models/pms_folio/__init__.py new file mode 100644 index 00000000000..01296020ffc --- /dev/null +++ b/connector_pms_wubook/models/pms_folio/__init__.py @@ -0,0 +1,12 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from . import adapter +from . import binder +from . import binding +from . import exporter +from . import importer +from . import mapper_export +from . import mapper_import +from . import pms_folio diff --git a/connector_pms_wubook/models/pms_folio/adapter.py b/connector_pms_wubook/models/pms_folio/adapter.py new file mode 100644 index 00000000000..11fee69fb88 --- /dev/null +++ b/connector_pms_wubook/models/pms_folio/adapter.py @@ -0,0 +1,257 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime + +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import Component +from odoo.addons.connector_pms.components.adapter import ChannelAdapterError + + +class ChannelWubookPmsFolioAdapter(Component): + _name = "channel.wubook.pms.folio" + _inherit = "channel.wubook.adapter" + + _apply_on = "channel.wubook.pms.folio" + + _id = "reservation_code" + + # CRUD + # pylint: disable=W8106 + def create(self, values): + # new_reservation(token, lcode, dfrom, dto, rooms, customer, + # amount[, origin= 'xml', ccard= 0, ancillary= 0, guests= 0, + # ignore_restrs= 0, ignore_avail= 0]) + # https://tdocs.wubook.net/wired/rsrvs.html#new_reservation + raise ChannelAdapterError(_("Create reservations is currently not supported")) + + def read(self, _id, ancillary=True): + # fetch_booking(token, lcode, rcode[, ancillary= False]) + # https://tdocs.wubook.net/wired/fetch.html#fetch_booking + kw_params = { + "rcode": _id, + } + if ancillary: + kw_params["ancillary"] = 1 + params = self._prepare_parameters(kw_params, ["rcode"]) + value = self._exec("fetch_booking", *params) + if not value: + return False + return value[0] + + def search_read(self, domain, ancillary=True, mark=False, only_codes=False): + """ " + * fetch_new_bookings(token, lcode[, ancillary=0, mark=1]) + https://tdocs.wubook.net/wired/fetch.html#fetch_new_bookings + + * fetch_bookings(token, lcode[, dfrom= None, dto= None, oncreated= 1, + ancillary= 0]) + https://tdocs.wubook.net/wired/fetch.html#fetch_bookings + If not filter (dfrom and dto) is specified, this call will be equal + to a fetch_new_bookings() call, with mark parameter = 1. + + * fetch_bookings_codes(token, lcode, dfrom, dto[, oncreated= 1]) + https://tdocs.wubook.net/wired/fetch.html#fetch_bookings_codes + + * fetch_booking(token, lcode, rcode[, ancillary= False]) + https://tdocs.wubook.net/wired/fetch.html#fetch_booking + """ + # TODO: refactor, split into smaller methods + kw_params = {} + date_field = "date_arrival" + domain_date, domain = self._extract_domain_clauses(domain, date_field) + if domain_date: + kw_params["oncreated"] = 0 + if self._extract_domain_clauses(domain, "date_received_time")[0]: + raise ValidationError( + _("Only allowed 'date_arrival' or 'date_received_time' not both") + ) + else: + date_field = "date_received_time" + domain_date, domain = self._extract_domain_clauses(domain, date_field) + if domain_date: + kw_params = { + **kw_params, + **self._domain_to_normalized_dict(domain_date, date_field), + } + date_field_args = [("%s_%%s" % date_field) % x for x in ["from", "dto"]] + if only_codes: + params = self._prepare_parameters( + kw_params, date_field_args, ["oncreated"] + ) + values = self._exec("fetch_bookings_codes", *params) + else: + if ancillary: + kw_params["ancillary"] = 1 + params = self._prepare_parameters( + kw_params, [], date_field_args + ["oncreated", "ancillary"] + ) + values = self._exec("fetch_bookings", *params) + if mark: + reservation_codes = [x[self._id] for x in values] + self.write( + reservation_codes, + { + "mark": 1, + }, + ) + else: + if ancillary: + kw_params["ancillary"] = 1 + reservation_codes = None + if domain: + domain_code = self._extract_domain_clauses(domain, self._id)[0] + if domain_code: + code_d = (self._domain_to_normalized_dict(domain_code),) + reservation_codes = code_d.get(self._id) + if reservation_codes: + if not isinstance(reservation_codes, (tuple, list)): + reservation_codes = [reservation_codes] + values = [] + for rcode in reservation_codes: + value = self.read(rcode, ancillary=ancillary) + if value: + values.append(value) + if mark: + self.write( + reservation_codes, + { + "mark": 1, + }, + ) + else: + kw_params["mark"] = mark and 1 or 0 + params = self._prepare_parameters(kw_params, [], ["ancillary", "mark"]) + # cycle call, 120 for each call, with mark=1 + # otherwise it goes to infinite loop + values = [] + while True: + value = self._exec("fetch_new_bookings", *params) + values += value + if not value or not mark: + break + + # https://tdocs.wubook.net/wired/fetch.html#reservation-representations + conv_mapper = { + "/dayprices": lambda x: int(x), + "/rooms": lambda x: [int(x) for x in x.split(",")], + "/date_received_time": lambda x: datetime.datetime.strptime( + x, "%s %%H:%%M:%%S" % self._date_format + ), + "/booked_rooms/roomdays/day": lambda x: datetime.datetime.strptime( + x, self._date_format + ).date(), + "/date_departure": lambda x: datetime.datetime.strptime( + x, self._date_format + ).date(), + "/date_arrival": lambda x: datetime.datetime.strptime( + x, self._date_format + ).date(), + "/boards": lambda x: int(x), + "/roomnight": lambda x: None, # useless + "/booked_rate": lambda x: None, # deprecated + "/date_received": lambda x: None, # deprecated + "/deleted_at": lambda x: None, # deprecated + } + self._convert_format(values, conv_mapper) + + values = self._filter(values, domain) + + # reformat rates + + # reformat data + for value in values: + # folio pricelist + value["rate_id"] = None + rate_ids = set() + for room in value["booked_rooms"]: + rate_ids |= {x["rate_id"] for x in room["roomdays"]} + if len(rate_ids) == 1: + value["rate_id"] = rate_ids.pop() + + # reservations + occupancies_d = { + x["id"]: x["occupancy"] for x in value.pop("rooms_occupancies") + } + boards_d = { + k: v != "nb" and v or None for k, v in value.pop("boards").items() + } + customer_notes = value.pop("customer_notes") + reservations = [] + for room in value.pop("booked_rooms"): + lines = [] + room_rate_id = None + for days in room["roomdays"]: + if room_rate_id is None: + room_rate_id = days["rate_id"] + else: + if room_rate_id != days["rate_id"]: + raise ValidationError( + _("Found different pricelists on the same reservation") + ) + lines.append( + { + "ancillary": days["ancillary"], + "price": days["price"], + "day": days["day"], + } + ) + room_id = room["room_id"] + reservations.append( + { + "room_id": room_id, + "arrival_hour": value["arrival_hour"], + "date_arrival": value["date_arrival"], + "date_departure": value["date_departure"], + "ancillary": room["ancillary"], + "board": boards_d[room_id], + "occupancy": occupancies_d[room_id], + "rate_id": room_rate_id, + "customer_notes": customer_notes, + "lines": lines, + } + ) + value["reservations"] = reservations + return values + + def search(self, domain): + values = self.search_read(domain) + ids = [x[self._id] for x in values] + return ids + + # pylint: disable=W8106 + def write(self, _ids, values): + # mark_bookings(token, lcode, reservations) + # https://tdocs.wubook.net/wired/fetch.html#mark_bookings + # cancel_reservation(token, lcode, rcode[, reason= '', send_voucher= 1]) + # https://tdocs.wubook.net/wired/rsrvs.html#cancel_reservation + # confirm_reservation(token, lcode, rcode[, reason= '', send_voucher= 1]) + # https://tdocs.wubook.net/wired/rsrvs.html#confirm_reservation + # reconfirm_reservation(token, lcode, rcode[, reason= '', send_voucher= 1]) + # https://tdocs.wubook.net/wired/rsrvs.html#reconfirm_reservation + if not isinstance(_ids, (tuple, list)): + _ids = [_ids] + + # plan values + if _ids: + if "mark" in values: + if values["mark"] == 1: + params = self._prepare_parameters( + {"reservations": _ids}, + ["reservations"], + ) + self._exec("mark_bookings", *params) + + def delete(self, _id): + raise ChannelAdapterError(_("Reservations cannot be deleted")) + + # MISC + def push_activation(self, url): + # https://tdocs.wubook.net/wired/fetch.html#push_activation + self._exec("push_activation", url) + + def push_url(self): + # https://tdocs.wubook.net/wired/fetch.html#push_url + url = self._exec("push_url") + return url diff --git a/connector_pms_wubook/models/pms_folio/binder.py b/connector_pms_wubook/models/pms_folio/binder.py new file mode 100644 index 00000000000..78ff72a62fb --- /dev/null +++ b/connector_pms_wubook/models/pms_folio/binder.py @@ -0,0 +1,20 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsFolioBinder(Component): + _name = "channel.wubook.pms.folio.binder" + _inherit = "channel.wubook.binder" + + _apply_on = "channel.wubook.pms.folio" + + # _internal_alt_id = "name" + # _external_alt_id = "name" + + # def _get_internal_record_alt(self, model_name, values): + # pass + + # TODO: find pricelist by name to link to backend when there's + # no binding diff --git a/connector_pms_wubook/models/pms_folio/binding.py b/connector_pms_wubook/models/pms_folio/binding.py new file mode 100644 index 00000000000..78f2417a1a0 --- /dev/null +++ b/connector_pms_wubook/models/pms_folio/binding.py @@ -0,0 +1,42 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ChannelWubookPmsFolioBinding(models.Model): + _name = "channel.wubook.pms.folio" + _inherit = "channel.wubook.binding" + _inherits = {"pms.folio": "odoo_id"} + + # binding fields + odoo_id = fields.Many2one( + comodel_name="pms.folio", + string="Odoo ID", + required=True, + ondelete="cascade", + ) + + @api.model + def import_data(self, backend_id, date_from, date_to, mark): + """ Prepare the batch import of Folios from Channel """ + domain = [] + if date_from and date_to: + domain += [ + ("date_arrival", ">=", date_from), + ("date_arrival", "<=", date_to), + ] + + return self.import_batch(backend_record=backend_id, domain=domain) + + # def write(self, values): + # # workaround to surpass an Odoo bug in a delegation inheritance + # # of product.pricelist that does not let to write 'name' field + # # if 'items_ids' is written as well on the same write call. + # # With other fields like 'sequence' it does not crash but it does not + # # save the value entered. For other fields it works but it's unstable. + # item_ids = values.pop("item_ids", None) + # if item_ids: + # super().write({"item_ids": item_ids}) + # if values: + # return super().write(values) diff --git a/connector_pms_wubook/models/pms_folio/exporter.py b/connector_pms_wubook/models/pms_folio/exporter.py new file mode 100644 index 00000000000..b7ef3ecfb4a --- /dev/null +++ b/connector_pms_wubook/models/pms_folio/exporter.py @@ -0,0 +1,26 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsFolioDelayedBatchExporter(Component): + _name = "channel.wubook.pms.folio.delayed.batch.exporter" + _inherit = "channel.wubook.delayed.batch.exporter" + + _apply_on = "channel.wubook.pms.folio" + + +class ChannelWubookPmsFolioDirectBatchExporter(Component): + _name = "channel.wubook.pms.folio.direct.batch.exporter" + _inherit = "channel.wubook.direct.batch.exporter" + + _apply_on = "channel.wubook.pms.folio" + + +class ChannelWubookPmsFolioExporter(Component): + _name = "channel.wubook.pms.folio.exporter" + _inherit = "channel.wubook.exporter" + + _apply_on = "channel.wubook.pms.folio" diff --git a/connector_pms_wubook/models/pms_folio/importer.py b/connector_pms_wubook/models/pms_folio/importer.py new file mode 100644 index 00000000000..a0e7b942f8c --- /dev/null +++ b/connector_pms_wubook/models/pms_folio/importer.py @@ -0,0 +1,34 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsFolioDelayedBatchImporter(Component): + _name = "channel.wubook.pms.folio.delayed.batch.importer" + _inherit = "channel.wubook.delayed.batch.importer" + + _apply_on = "channel.wubook.pms.folio" + + +class ChannelWubookPmsFolioDirectBatchImporter(Component): + _name = "channel.wubook.pms.folio.direct.batch.importer" + _inherit = "channel.wubook.direct.batch.importer" + + _apply_on = "channel.wubook.pms.folio" + + +class ChannelWubookPmsFolioImporter(Component): + _name = "channel.wubook.pms.folio.importer" + _inherit = "channel.wubook.importer" + + _apply_on = "channel.wubook.pms.folio" + + def _import_dependencies(self, external_data, external_fields): + # if not external_fields or 'reservations' in external_fields: + for reserv in external_data["reservations"]: + self._import_dependency(reserv["room_id"], "channel.wubook.pms.room.type") + self._import_dependency( + reserv["rate_id"], "channel.wubook.product.pricelist" + ) diff --git a/connector_pms_wubook/models/pms_folio/mapper_export.py b/connector_pms_wubook/models/pms_folio/mapper_export.py new file mode 100644 index 00000000000..037e1e7bfac --- /dev/null +++ b/connector_pms_wubook/models/pms_folio/mapper_export.py @@ -0,0 +1,21 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping + + +class ChannelWubookPmsFolioMapperExport(Component): + _name = "channel.wubook.pms.folio.mapper.export" + _inherit = "channel.wubook.mapper.export" + + _apply_on = "channel.wubook.pms.folio" + + direct = [ + ("name", "name"), + ] + + @mapping + def name(self, record): + return {"name": record["name"]} diff --git a/connector_pms_wubook/models/pms_folio/mapper_import.py b/connector_pms_wubook/models/pms_folio/mapper_import.py new file mode 100644 index 00000000000..74a795cf033 --- /dev/null +++ b/connector_pms_wubook/models/pms_folio/mapper_import.py @@ -0,0 +1,99 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping, only_create + + +class ChannelWubookPmsFolioMapperImport(Component): + _name = "channel.wubook.pms.folio.mapper.import" + _inherit = "channel.wubook.mapper.import" + + _apply_on = "channel.wubook.pms.folio" + + direct = [ + # ("men", "adults"), + # ("children", "children"), + ] + + children = [ + ("reservations", "reservation_ids", "channel.wubook.pms.reservation"), + ] + + @only_create + @mapping + def backend_id(self, record): + return {"backend_id": self.backend_record.id} + + @only_create + @mapping + def property_id(self, record): + return {"pms_property_id": self.backend_record.pms_property_id.id} + + @only_create + @mapping + def pricelist_id(self, record): + pricelist_id = False + if record["rate_id"]: + binder = self.binder_for("channel.wubook.product.pricelist") + pricelist = binder.to_internal(record["rate_id"], unwrap=True) + assert pricelist, ( + "rate_id %s should have been imported in " + "ProductPricelistImporter._import_dependencies" % (record["rate_id"],) + ) + pricelist_id = pricelist.id + # TODO: + if pricelist_id: + return {"pricelist_id": pricelist_id} + + @only_create + @mapping + def partner_id(self, record): + values = { + "name": "{}, {}".format( + record["customer_surname"], record["customer_name"] + ), + "city": record["customer_city"], + "phone": record["customer_phone"], + "zip": record["customer_zip"], + "street": record["customer_address"], + "email": record["customer_mail"], + } + country = self.env["res.country"].search( + [("code", "=", record["customer_country"])], limit=1 + ) + if country: + values["country_id"] = (country.id,) + lang = self.env["res.lang"].search( + [("code", "=", record["customer_language_iso"])], limit=1 + ) + if lang: + values["lang"] = lang.id + partner = self.env["res.partner"].create(values) + return {"partner_id": partner.id} + + +class ChannelWubookPmsFolioChildMapperImport(Component): + _name = "channel.wubook.pms.folio.child.mapper.import" + _inherit = "channel.wubook.child.mapper.import" + _apply_on = "channel.wubook.pms.reservation" + + def get_item_values(self, map_record, to_attr, options): + values = super().get_item_values(map_record, to_attr, options) + binding = options.get("binding") + if binding: + # TODO heuristic to decide how to update existing reservations + pass + else: + return values + + def format_items(self, items_values): + ops = [] + for values in items_values: + _id = values.pop("id", None) + if _id: + ops.append((1, _id, values)) + else: + ops.append((0, 0, values)) + + return ops diff --git a/connector_pms_wubook/models/pms_folio/pms_folio.py b/connector_pms_wubook/models/pms_folio/pms_folio.py new file mode 100644 index 00000000000..259b26a143d --- /dev/null +++ b/connector_pms_wubook/models/pms_folio/pms_folio.py @@ -0,0 +1,14 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PmsFolio(models.Model): + _inherit = "pms.folio" + + channel_wubook_bind_ids = fields.One2many( + comodel_name="channel.wubook.pms.folio", + inverse_name="odoo_id", + string="Channel Wubook PMS Bindings", + ) diff --git a/connector_pms_wubook/models/pms_reservation/__init__.py b/connector_pms_wubook/models/pms_reservation/__init__.py new file mode 100644 index 00000000000..8e1f10bb048 --- /dev/null +++ b/connector_pms_wubook/models/pms_reservation/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from . import binding +from . import mapper_export +from . import mapper_import diff --git a/connector_pms_wubook/models/pms_reservation/binding.py b/connector_pms_wubook/models/pms_reservation/binding.py new file mode 100644 index 00000000000..5392bff304b --- /dev/null +++ b/connector_pms_wubook/models/pms_reservation/binding.py @@ -0,0 +1,17 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ChannelWubookPmsReservationBinding(models.Model): + _name = "channel.wubook.pms.reservation" + _inherit = "channel.wubook.binding" + _inherits = {"pms.reservation": "odoo_id"} + + odoo_id = fields.Many2one( + comodel_name="pms.reservation", + string="Odoo ID", + required=True, + ondelete="cascade", + ) diff --git a/connector_pms_wubook/models/pms_reservation/mapper_export.py b/connector_pms_wubook/models/pms_reservation/mapper_export.py new file mode 100644 index 00000000000..15724b68cd9 --- /dev/null +++ b/connector_pms_wubook/models/pms_reservation/mapper_export.py @@ -0,0 +1,22 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + +# from odoo.addons.connector.components.mapper import mapping + + +class ChannelWubookPmsReservationMapperExport(Component): + _name = "channel.wubook.pms.reservation.mapper.export" + _inherit = "channel.wubook.mapper.export" + + _apply_on = "channel.wubook.pms.reservation" + + # direct = [ + # ("name", "name"), + # ] + # + # @mapping + # def name(self, record): + # return {"name": record['name']} diff --git a/connector_pms_wubook/models/pms_reservation/mapper_import.py b/connector_pms_wubook/models/pms_reservation/mapper_import.py new file mode 100644 index 00000000000..ac3de9b55d4 --- /dev/null +++ b/connector_pms_wubook/models/pms_reservation/mapper_import.py @@ -0,0 +1,180 @@ +# 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 Component +from odoo.addons.connector.components.mapper import mapping, only_create + + +class ChannelWubookPmsReservationMapperImport(Component): + _name = "channel.wubook.pms.reservation.mapper.import" + _inherit = "channel.wubook.mapper.import" + + _apply_on = "channel.wubook.pms.reservation" + + children = [ + ("lines", "reservation_line_ids", "channel.wubook.pms.reservation.line"), + ] + + @only_create + @mapping + def reservations(self, record): + values = { + "pms_property_id": self.backend_record.pms_property_id.id, + "arrival_hour": record["arrival_hour"], + "checkin": record["date_arrival"], + "checkout": record["date_departure"], + "adults": record["occupancy"], + } + + rt_binder = self.binder_for("channel.wubook.pms.room.type") + room_type = rt_binder.to_internal(record["room_id"], unwrap=True) + assert room_type, ( + "room_id %s should have been imported in " + "PmsRoomTypeImporter._import_dependencies" % (record["room_id"],) + ) + values["room_type_id"] = room_type.id + + if record["board"]: + bd_binder = self.binder_for("channel.wubook.pms.board.service") + board_service = bd_binder.to_internal(record["board"], unwrap=True) + assert board_service, ( + "board_service_id '%s' should've been imported in " + "PmsRoomTypeImporter._import_dependencies.\n" + "Make sure the Room Type '%s' has that Board Service '%s' " + "defined in the backend." + % (record["board"], room_type.default_code, record["board"]) + ) + board_service_room_type_id = room_type.board_service_room_type_ids.filtered( + lambda x: x.pms_board_service_id == board_service + ) + if not board_service_room_type_id: + raise ValidationError( + _("The Board Service '%s' is not available in Room Type '%s'") + % (board_service.default_code, room_type.default_code) + ) + elif len(board_service_room_type_id) > 1: + raise ValidationError( + _("The Board Service '%s' is duplicated in Room Type '%s'") + % (board_service.default_code, room_type.default_code) + ) + values["board_service_room_id"] = board_service_room_type_id.id + + # pl_binder = self.binder_for("channel.wubook.product.pricelist") + # pricelist = pl_binder.to_internal(record["rate_id"], unwrap=True) + # assert pricelist, ( + # "rate_id %s should have been imported in " + # "ProductPricelistImporter._import_dependencies" % (record['rate_id'],)) + + # values["pricelist_id"] = pricelist.id + # values["pricelist_id"] = self.env.ref('product.list0').id + + # partner_id + # values["partner_id"] = record.partner_id.id + + return values + + @only_create + @mapping + def dates(self, record): + return { + "arrival_hour": record["arrival_hour"], + "checkin": record["date_arrival"], + "checkout": record["date_departure"], + } + + @only_create + @mapping + def requests(self, record): + return { + "partner_requests": record["customer_notes"], + } + + # ttype = record["type"] + # if ttype == "pricelist": + # pl_binder = self.binder_for("channel.wubook.product.pricelist") + # pricelist = pl_binder.to_internal(record["vpid"], unwrap=True) + # if not pricelist: + # raise ValidationError( + # _( + # "External record with id %i not exists. " + # "It should be imported in _import_dependencies" + # ) + # % record["vpid"] + # ) + # values = { + # "applied_on": "3_global", + # "compute_price": "formula", + # "base": "pricelist", + # "base_pricelist_id": pricelist.id, + # } + # variation_type = record["variation_type"] + # variation = record["variation"] + # if variation_type == -2: + # values["price_discount"] = 0 + # values["price_surcharge"] = -variation + # elif variation_type == -1: + # values["price_discount"] = variation + # values["price_surcharge"] = 0 + # elif variation_type == 1: + # values["price_discount"] = -variation + # values["price_surcharge"] = 0 + # elif variation_type == 2: + # values["price_discount"] = 0 + # values["price_surcharge"] = variation + # else: + # raise ValidationError(_("Unknown variation type %s") % variation_type) + # elif ttype == "room": + # # TODO + # pass + # else: + # raise ValidationError(_("Unknown type '%s'") % ttype) + # return values + + +class ChannelWubookPmsReservationChildMapperImport(Component): + _name = "channel.wubook.pms.reservation.child.mapper.import" + _inherit = "channel.wubook.child.mapper.import" + _apply_on = "channel.wubook.pms.reservation.line" + + # def get_item_values(self, map_record, to_attr, options): + # values = super().get_item_values(map_record, to_attr, options) + # common_keys = {"applied_on", "compute_price"} + # if {*common_keys, "base", "base_pricelist_id"}.issubset(values): + # binding = options.get("binding") + # if binding: + # item_ids = binding.item_ids.filtered( + # lambda x: all( + # [ + # x.applied_on == values["applied_on"], + # x.compute_price == values["compute_price"], + # x.base == values["base"], + # x.base_pricelist_id.id == values["base_pricelist_id"], + # ] + # ) + # ) + # if item_ids: + # if len(item_ids) > 1: + # raise ValidationError( + # _( + # "Found two pricelist items with same properties %s. " + # "Please remove one of them" + # ) + # % values + # ) + # values["id"] = item_ids.id + # + # return values + + # def format_items(self, items_values): + # ops = [] + # for values in items_values: + # _id = values.pop("id", None) + # if _id: + # ops.append((1, _id, values)) + # else: + # ops.append((0, 0, values)) + # + # return ops diff --git a/connector_pms_wubook/models/pms_reservation_line/__init__.py b/connector_pms_wubook/models/pms_reservation_line/__init__.py new file mode 100644 index 00000000000..8e1f10bb048 --- /dev/null +++ b/connector_pms_wubook/models/pms_reservation_line/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from . import binding +from . import mapper_export +from . import mapper_import diff --git a/connector_pms_wubook/models/pms_reservation_line/binding.py b/connector_pms_wubook/models/pms_reservation_line/binding.py new file mode 100644 index 00000000000..7467afd60b5 --- /dev/null +++ b/connector_pms_wubook/models/pms_reservation_line/binding.py @@ -0,0 +1,17 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ChannelWubookPmsReservationLineBinding(models.Model): + _name = "channel.wubook.pms.reservation.line" + _inherit = "channel.wubook.binding" + _inherits = {"pms.reservation.line": "odoo_id"} + + odoo_id = fields.Many2one( + comodel_name="pms.reservation.line", + string="Odoo ID", + required=True, + ondelete="cascade", + ) diff --git a/connector_pms_wubook/models/pms_reservation_line/mapper_export.py b/connector_pms_wubook/models/pms_reservation_line/mapper_export.py new file mode 100644 index 00000000000..9dd851129b9 --- /dev/null +++ b/connector_pms_wubook/models/pms_reservation_line/mapper_export.py @@ -0,0 +1,22 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + +# from odoo.addons.connector.components.mapper import mapping + + +class ChannelWubookPmsReservationLineMapperExport(Component): + _name = "channel.wubook.pms.reservation.line.mapper.export" + _inherit = "channel.wubook.mapper.export" + + _apply_on = "channel.wubook.pms.reservation.line" + + # direct = [ + # ("name", "name"), + # ] + # + # @mapping + # def name(self, record): + # return {"name": record['name']} diff --git a/connector_pms_wubook/models/pms_reservation_line/mapper_import.py b/connector_pms_wubook/models/pms_reservation_line/mapper_import.py new file mode 100644 index 00000000000..e78b65b2884 --- /dev/null +++ b/connector_pms_wubook/models/pms_reservation_line/mapper_import.py @@ -0,0 +1,24 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsReservationLineMapperImport(Component): + _name = "channel.wubook.pms.reservation.line.mapper.import" + _inherit = "channel.wubook.mapper.import" + + _apply_on = "channel.wubook.pms.reservation.line" + + direct = [ + ("price", "price"), + ("day", "date"), + ] + + # @mapping + # def lines(self, record): + # return { + # 'price': record['price'], + # 'date': record['day'], + # } diff --git a/connector_pms_wubook/models/pms_room_type/__init__.py b/connector_pms_wubook/models/pms_room_type/__init__.py new file mode 100644 index 00000000000..d078f4d1039 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type/__init__.py @@ -0,0 +1,12 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from . import adapter +from . import binder +from . import binding +from . import exporter +from . import importer +from . import mapper_export +from . import mapper_import +from . import pms_room_type diff --git a/connector_pms_wubook/models/pms_room_type/adapter.py b/connector_pms_wubook/models/pms_room_type/adapter.py new file mode 100644 index 00000000000..e59940274a3 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type/adapter.py @@ -0,0 +1,128 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _ + +from odoo.addons.component.core import Component +from odoo.addons.connector_pms.components.adapter import ChannelAdapterError + + +class ChannelWubookPmsRoomTypeAdapter(Component): + _name = "channel.wubook.pms.room.type.adapter" + _inherit = "channel.wubook.adapter" + + _apply_on = "channel.wubook.pms.room.type" + + # CRUD + # pylint: disable=W8106 + def create(self, values): + # https://tdocs.wubook.net/wired/rooms.html#new_room + params = self._prepare_parameters( + values, + [ + "woodoo", + "name", + "occupancy", + "price", + "availability", + "shortname", + "board", + ], + [ + "names", + "descriptions", + "boards", + "rtype", + ("min_price", 0), + ("max_price", 0), + ], + ) + _id = self._exec("new_room", *params) + return _id + + def read(self, _id, ancillary=None): + # https://tdocs.wubook.net/wired/rooms.html#fetch_single_room + values = {"id": _id} + if ancillary is not None: + values["ancillary"] = ancillary + params = self._prepare_parameters(values, ["id"], ["ancillary"]) + values = self._exec("fetch_single_room", *params) + if not values: + raise ChannelAdapterError(_("No room type found with id '%s'") % _id) + if len(values) != 1: + raise ChannelAdapterError(_("Received more than one room %s") % (values,)) + self._format_values(values) + return values[0] + + def search_read(self, domain, ancillary=None): + # https://tdocs.wubook.net/wired/rooms.html#fetch_rooms + values = {} + if ancillary is not None: + values["ancillary"] = ancillary + params = self._prepare_parameters(values, [], ["ancillary"]) + values = self._exec("fetch_rooms", *params) + values = self._filter(values, domain) + self._format_values(values) + return values + + def search(self, domain, ancillary=None): + # https://tdocs.wubook.net/wired/rooms.html#fetch_rooms + values = self.search_read(domain, ancillary=ancillary) + ids = [x[self._id] for x in values] + return ids + + # pylint: disable=W8106 + def write(self, _id, values): + # https://tdocs.wubook.net/wired/rooms.html#mod_room + params = self._prepare_parameters( + values, + ["name", "occupancy", "price", "availability", "shortname", "board"], + [ + "names", + "descriptions", + "boards", + ("min_price", 0), + ("max_price", 0), + "rtype", + "woodoo", + ], + ) + _id = self._exec("mod_room", _id, *params) + return _id + + def delete(self, _id): + # https://tdocs.wubook.net/wired/rooms.html#del_room + res = self._exec("del_room", _id) + return res + + # MISC + def images(self, _id): + # https://tdocs.wubook.net/wired/rooms.html#room_images + values = self._exec("room_images", _id) + return values + + def push_update_activation(self, url): + # https://tdocs.wubook.net/wired/rooms.html#push_update_activation + self._exec("push_update_activation", url) + + def push_update_url(self): + # https://tdocs.wubook.net/wired/rooms.html#push_update_url + url = self._exec("push_update_url") + return url + + def _format_values(self, values): + for v in values: + # boards + default_board_id = v.pop("board") + boards = v.pop("boards") or {} + boards[default_board_id] = {} + if "nb" in boards: + boards.pop("nb") + v["boards"] = [] + for board_id, params in boards.items(): + v["boards"].append( + { + "id": board_id, + "default": board_id == default_board_id and 1 or 0, + **params, + } + ) diff --git a/connector_pms_wubook/models/pms_room_type/binder.py b/connector_pms_wubook/models/pms_room_type/binder.py new file mode 100644 index 00000000000..9b7702ae33c --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type/binder.py @@ -0,0 +1,63 @@ +# 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 Component + + +class ChannelWubookPmsRoomTypeBinder(Component): + _name = "channel.wubook.pms.room.type.binder" + _inherit = "channel.wubook.binder" + + _apply_on = "channel.wubook.pms.room.type" + + _internal_alt_id = ("default_code", "pms_property_ids") + _external_alt_id = "shortname" + + def _get_internal_record_alt(self, model_name, values): + pms_property_id = values["pms_property_ids"][0][1] + pms_property = self.env["pms.property"].browse(pms_property_id) + company_id = pms_property.company_id.id + + records = self.env[model_name].search( + [ + "&", + ("default_code", "=", values["default_code"]), + "|", + "|", + ("pms_property_ids", "in", pms_property_id), + ("pms_property_ids.company_id", "in", [company_id]), + "|", + "&", + ("pms_property_ids", "=", False), + ("company_id", "=", company_id), + "&", + ("pms_property_ids", "=", False), + ("company_id", "=", False), + ] + ) + + res, res_priority = self.env[model_name], -1 + for rec in records: + priority = ( + (rec.pms_property_ids and pms_property in rec.pms_property_ids and 3) + or ( + rec.pms_property_ids + and company_id in rec.pms_property_ids.mapped("company_id.id") + and 2 + ) + or (rec.company_id and 1 or 0) + ) + if priority > res_priority: + res, res_priority = rec, priority + elif priority == res_priority: + raise ValidationError( + _( + "Integrity error: There's more than one room type " + "with same code and properties" + ) + ) + return res + + # TODO: almost identical code as on board servie and room class -> Unify diff --git a/connector_pms_wubook/models/pms_room_type/binding.py b/connector_pms_wubook/models/pms_room_type/binding.py index 20f4481825d..497d948a959 100644 --- a/connector_pms_wubook/models/pms_room_type/binding.py +++ b/connector_pms_wubook/models/pms_room_type/binding.py @@ -1,39 +1,118 @@ # Copyright 2021 Eric Antones # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import models, fields, api - -from odoo.addons.component.core import Component -from odoo.addons.queue_job.job import job -from odoo import exceptions +from odoo import api, fields, models class ChannelWubookPmsRoomTypeBinding(models.Model): - _name = 'channel.wubook.pms.room.type' - _inherits = {'channel.pms.room.type': 'parent_id'} - - parent_id = fields.Many2one(comodel_name='channel.pms.room.type', - string='Room Type Binding', - required=True, - ondelete='cascade') - - # @job(default_channel='root.channel') - # @api.model - # def import_data(self, backend_record=None): - # """ Prepare the batch import of room types from Channel """ - # return self.import_batch(backend=backend_record) - - # @api.multi - # def resync(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.import_record - # if record.env.context.get('connector_delay'): - # func = record.import_record.delay - # - # func(record.backend_id, relation) - # - # return True + _name = "channel.wubook.pms.room.type" + _inherit = "channel.wubook.binding" + _inherits = {"pms.room.type": "odoo_id"} + + @api.model + def _default_max_avail(self): + return ( + self.env["pms.room.type"] + .browse(self._context.get("default_odoo_id")) + .total_rooms_count + or -1 + ) + + @api.model + def _default_availability(self): + return max(min(self.default_quota, self.default_max_avail), 0) + + # binding fields + odoo_id = fields.Many2one( + comodel_name="pms.room.type", + string="Odoo ID", + required=True, + ondelete="cascade", + ) + + # model fields + # TODO: are these fields really necessary?? + occupancy = fields.Integer( + string="Occupancy", + default=1, + help="The occupancy/capacity/beds of the rooms (children included)", + ) + default_quota = fields.Integer( + string="Default Quota", + help="Quota assigned to the channel given no availability rules. " + "Use `-1` for managing no quota.", + ) + default_max_avail = fields.Integer( + string="Max. Availability", + default=_default_max_avail, + help="Maximum simultaneous availability given no availability rules. " + "Use `-1` for using maximum simultaneous availability.", + ) + default_availability = fields.Integer( + string="Availability", + default=_default_availability, + readonly=True, + help="Default availability for OTAs. " + "The availability is calculated based on the quota, " + "the maximum simultaneous availability and " + "the total room count for the given room type.", + ) + min_price = fields.Float( + "Min. Price", + default=5.0, + digits="Product Price", + help="Setup the min price to prevent incidents while editing your prices.", + ) + max_price = fields.Float( + "Max. Price", + default=200.0, + digits="Product Price", + help="Setup the max price to prevent incidents while editing your prices.", + ) + + # TODO: Is this check really needed??? + # @api.constrains('min_price', 'max_price') + # def _check_min_max_price(self): + # for rec in self: + # if rec.min_price < 5 or rec.max_price < 5: + # raise ValidationError( + # _("The channel manager limits the minimum value " + # "of min price and max price to 5.")) + + @api.model + def export_data(self, backend_record=None): + """ Prepare the batch export of Room Types to Channel """ + room_types = self.odoo_id.get_room_types_by_property( + backend_record.pms_property_id.id + ) + return self.export_batch( + backend_record=backend_record, + domain=[ + ("id", "in", room_types.ids), + # ("default_code", "=", "ECO") + ], + ) + + # def write(self, values): + # # workaround to surpass an Odoo bug in a delegation inheritance + # # of pms.room.type that does not let to write 'name' field + # # if 'items_ids' is written as well on the same write call. + # # With other fields like 'sequence' it does not crash but it does not + # # save the value entered. For other fields it works but it's unstable. + # boards = values.pop("board_service_room_type_ids", None) + # if boards: + # super(ChannelWubookPmsRoomTypeBinding, self).write({"board_service_room_type_ids": boards}) + # if values: + # return super(ChannelWubookPmsRoomTypeBinding, self).write(values) + + # "men": 5, + # "subroom": 0, + # "occupancy": 5, + # "board": "ai", + # "availability": 5, + # "shortname": "H217", + # "children": 0, + # "boards": "", + # "anchorate": 0, + # "dec_avail": 0, + # "woodoo": 0, diff --git a/connector_pms_wubook/models/pms_room_type/exporter.py b/connector_pms_wubook/models/pms_room_type/exporter.py new file mode 100644 index 00000000000..0f1430493d2 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type/exporter.py @@ -0,0 +1,35 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsRoomTypeDelayedBatchExporter(Component): + _name = "channel.wubook.pms.room.type.delayed.batch.exporter" + _inherit = "channel.wubook.delayed.batch.exporter" + + _apply_on = "channel.wubook.pms.room.type" + + +class ChannelWubookPmsRoomTypeDirectBatchExporter(Component): + _name = "channel.wubook.pms.room.type.direct.batch.exporter" + _inherit = "channel.wubook.direct.batch.exporter" + + _apply_on = "channel.wubook.pms.room.type" + + +class ChannelWubookPmsRoomTypeExporter(Component): + _name = "channel.wubook.pms.room.type.exporter" + _inherit = "channel.wubook.exporter" + + _apply_on = "channel.wubook.pms.room.type" + + def _export_dependencies(self): + self._export_dependency( + self.binding.class_id, "channel.wubook.pms.room.type.class" + ) + for board_service in self.binding.board_service_room_type_ids.mapped( + "pms_board_service_id" + ): + self._export_dependency(board_service, "channel.wubook.pms.board.service") diff --git a/connector_pms_wubook/models/pms_room_type/importer.py b/connector_pms_wubook/models/pms_room_type/importer.py new file mode 100644 index 00000000000..dddb6f3554d --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type/importer.py @@ -0,0 +1,35 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsRoomTypeDelayedBatchImporter(Component): + _name = "channel.wubook.pms.room.type.delayed.batch.importer" + _inherit = "channel.wubook.delayed.batch.importer" + + _apply_on = "channel.wubook.pms.room.type" + + +class ChannelWubookPmsRoomTypeDirectBatchImporter(Component): + _name = "channel.wubook.pms.room.type.direct.batch.importer" + _inherit = "channel.wubook.direct.batch.importer" + + _apply_on = "channel.wubook.pms.room.type" + + +class ChannelWubookPmsRoomTypeImporter(Component): + _name = "channel.wubook.pms.room.type.importer" + _inherit = "channel.wubook.importer" + + _apply_on = "channel.wubook.pms.room.type" + + def _import_dependencies(self, external_data, external_fields): + # if not external_fields or 'rtype' in external_fields: + self._import_dependency( + external_data["rtype"], "channel.wubook.pms.room.type.class" + ) + # if not external_fields or 'boards' in external_fields: + for board in external_data["boards"]: + self._import_dependency(board["id"], "channel.wubook.pms.board.service") diff --git a/connector_pms_wubook/models/pms_room_type/mapper_export.py b/connector_pms_wubook/models/pms_room_type/mapper_export.py new file mode 100644 index 00000000000..5dfaf0a09c0 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type/mapper_export.py @@ -0,0 +1,95 @@ +# 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 Component +from odoo.addons.connector.components.mapper import mapping + + +class ChannelWubookPmsRoomTypeMapperExport(Component): + _name = "channel.wubook.pms.room.type.mapper.export" + _inherit = "channel.wubook.mapper.export" + + _apply_on = "channel.wubook.pms.room.type" + + direct = [ + ("name", "name"), + ("occupancy", "occupancy"), + ("list_price", "price"), + ("default_code", "shortname"), + ("min_price", "min_price"), + ("max_price", "max_price"), + ] + + children = [ + ( + "board_service_room_type_ids", + "boards", + "channel.wubook.pms.room.type.board.service", + ), + ] + + @mapping + def occupancy(self, record): + return {"availability": len(record["room_ids"])} + + @mapping + def default_board_service(self, record): + default_board_service = record.board_service_room_type_ids.filtered( + lambda x: x.by_default + ).mapped("pms_board_service_id") + if not default_board_service: + return {"board": "nb"} + if len(default_board_service) != 1: + raise ValidationError( + _( + "Room type %s: The number of default Board Services must be exactly 1" + ) + % record.name + ) + + bs_binder = self.binder_for("channel.wubook.pms.board.service") + external_id = bs_binder.to_external(default_board_service, wrap=True) + if not external_id: + raise ValidationError( + _( + "External record of Board Service [%s] %s does not exists. " + "It should be exported in _export_dependencies" + ) + % (default_board_service.default_code, default_board_service.name) + ) + return {"board": external_id} + + @mapping + def room_type_class(self, record): + room_class = record.class_id + rc_binder = self.binder_for("channel.wubook.pms.room.type.class") + external_id = rc_binder.to_external(room_class, wrap=True) + if not external_id: + raise ValidationError( + _( + "External record of Room Class [%s] %s does not exists. " + "It should be exported in _export_dependencies" + ) + % (room_class.default_code, room_class.name) + ) + return {"rtype": external_id} + + @mapping + def woodoo(self, record): + return {"woodoo": 0} + + +class ChannelWubookPmsRoomTypeBoardServiceChildMapperExport(Component): + _name = "channel.wubook.pms.room.type.board.service.child.mapper.export" + _inherit = "channel.wubook.child.mapper.export" + _apply_on = "channel.wubook.pms.room.type.board.service" + + def format_items(self, items_values): + # avoid adding many2one crud operation codes + values = {} + for item in items_values: + values.update(item) + return values diff --git a/connector_pms_wubook/models/pms_room_type/mapper_import.py b/connector_pms_wubook/models/pms_room_type/mapper_import.py new file mode 100644 index 00000000000..e0c742094c2 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type/mapper_import.py @@ -0,0 +1,139 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import random + +from odoo import _ + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping, only_create + + +class ChannelWubookPmsRoomTypeMapperImport(Component): + _name = "channel.wubook.pms.room.type.mapper.import" + _inherit = "channel.wubook.mapper.import" + + _apply_on = "channel.wubook.pms.room.type" + + direct = [ + ("name", "name"), + ("occupancy", "occupancy"), + ("availability", "default_availability"), + ("board", "default_board"), + ("price", "list_price"), + ("min_price", "min_price"), + ("max_price", "max_price"), + ] + + children = [ + ( + "boards", + "board_service_room_type_ids", + "channel.wubook.pms.room.type.board.service", + ), + ] + + @only_create + @mapping + def backend_id(self, record): + return {"backend_id": self.backend_record.id} + + @only_create + @mapping + def default_code(self, record): + return { + "default_code": record["shortname"], + } + + @mapping + def class_id(self, record): + binder = self.binder_for("channel.wubook.pms.room.type.class") + room_type_class = binder.to_internal(record["rtype"], unwrap=True) + return { + "class_id": room_type_class.id, + } + + @mapping + def room_ids(self, record): + room_ids = [] + binding = self.options.get("binding") + if binding: + room_ids = binding.room_ids.filtered( + lambda x: x.pms_property_id == self.backend_record.pms_property_id + ).ids + + diff_rooms = record["availability"] - len(room_ids) + if diff_rooms < 0: + raise NotImplementedError( + _("Found more rooms on PMS Room Type '%s' than on the backend") + % (binding.default_code,) + ) + elif diff_rooms > 0: + return { + "room_ids": [ + ( + 0, + 0, + { + "name": "TEMP-%s" % format(random.randint(0, 0xFFFF), "x"), + "pms_property_id": self.backend_record.pms_property_id.id, + "capacity": record["occupancy"], + }, + ) + for x in range(diff_rooms) + ] + } + + @mapping + def property_ids(self, record): + binding = self.options.get("binding") + has_pms_properties = binding and bool(binding.pms_property_ids) + if self.options.for_create or has_pms_properties: + return { + "pms_property_ids": [(4, self.backend_record.pms_property_id.id, 0)] + } + + +class ChannelWubookPmsRoomTypeBoardServiceChildMapperImport(Component): + _name = "channel.wubook.pms.room.type.board.service.child.mapper.import" + _inherit = "channel.wubook.child.mapper.import" + _apply_on = "channel.wubook.pms.room.type.board.service" + + # def get_item_values(self, map_record, to_attr, options): + # values = super().get_item_values(map_record, to_attr, options) + # binding = options.get("binding") + # if binding: + # raise NotImplementedError() + # # item_ids = binding.rule_ids.filtered( + # # lambda x: all( + # # [ + # # x.date == values["date"], + # # x.room_type_id.id == values["room_type_id"], + # # x.pms_property_id.id == values["pms_property_id"], + # # ] + # # ) + # # ) + # # if item_ids: + # # if len(item_ids) > 1: + # # raise ValidationError( + # # _( + # # "Found two pricelist items with same properties %s. " + # # "Please remove one of them" + # # ) + # # % values + # # ) + # # values["id"] = item_ids.id + # # + # return values + # + # def format_items(self, items_values): + # ops = [] + # # TODO: the next code is always the same, put it on a common parent + # for values in items_values: + # _id = values.pop("id", None) + # if _id: + # ops.append((1, _id, values)) + # else: + # ops.append((0, 0, values)) + # + # return ops diff --git a/connector_pms_wubook/models/pms_room_type/pms_room_type.py b/connector_pms_wubook/models/pms_room_type/pms_room_type.py new file mode 100644 index 00000000000..a45c7727e57 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type/pms_room_type.py @@ -0,0 +1,14 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PmsRoomType(models.Model): + _inherit = "pms.room.type" + + channel_wubook_bind_ids = fields.One2many( + comodel_name="channel.wubook.pms.room.type", + inverse_name="odoo_id", + string="Channel Wubook PMS Bindings", + ) diff --git a/connector_pms_wubook/models/pms_room_type_board_service/__init__.py b/connector_pms_wubook/models/pms_room_type_board_service/__init__.py new file mode 100644 index 00000000000..8e1f10bb048 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_board_service/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from . import binding +from . import mapper_export +from . import mapper_import diff --git a/connector_pms_wubook/models/pms_room_type_board_service/binding.py b/connector_pms_wubook/models/pms_room_type_board_service/binding.py new file mode 100644 index 00000000000..74e12d869b6 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_board_service/binding.py @@ -0,0 +1,17 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ChannelWubookPmsRoomTypeBoardServiceBinding(models.Model): + _name = "channel.wubook.pms.room.type.board.service" + _inherit = "channel.wubook.binding" + _inherits = {"pms.board.service.room.type": "odoo_id"} + + odoo_id = fields.Many2one( + comodel_name="pms.board.service.room.type", + string="Odoo ID", + required=True, + ondelete="cascade", + ) diff --git a/connector_pms_wubook/models/pms_room_type_board_service/mapper_export.py b/connector_pms_wubook/models/pms_room_type_board_service/mapper_export.py new file mode 100644 index 00000000000..8479341f378 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_board_service/mapper_export.py @@ -0,0 +1,30 @@ +# 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 Component +from odoo.addons.connector.components.mapper import mapping + + +class ChannelWubookPmsRoomTypeBoardServiceMapperExport(Component): + _name = "channel.wubook.pms.room.type.board.service.mapper.export" + _inherit = "channel.wubook.mapper.export" + + _apply_on = "channel.wubook.pms.room.type.board.service" + + @mapping + def boards(self, record): + board_service = record.pms_board_service_id + bs_binder = self.binder_for("channel.wubook.pms.board.service") + external_id = bs_binder.to_external(board_service, wrap=True) + if not external_id: + raise ValidationError( + _( + "External record of Board Service [%s] %s does not exists. " + "It should be exported in _export_dependencies" + ) + % (board_service.default_code, board_service.name) + ) + return {external_id: {"dtype": 2, "value": 0}} diff --git a/connector_pms_wubook/models/pms_room_type_board_service/mapper_import.py b/connector_pms_wubook/models/pms_room_type_board_service/mapper_import.py new file mode 100644 index 00000000000..9f1dc5f15e9 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_board_service/mapper_import.py @@ -0,0 +1,27 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping, only_create + + +class ChannelWubookPmsRoomTypeBoardServiceMapperImport(Component): + _name = "channel.wubook.pms.room.type.board.service.mapper.import" + _inherit = "channel.wubook.mapper.import" + + _apply_on = "channel.wubook.pms.room.type.board.service" + + @only_create + @mapping + def boards(self, record): + bd_binder = self.binder_for("channel.wubook.pms.board.service") + board_service = bd_binder.to_internal(record["id"], unwrap=True) + assert board_service, ( + "board_service_id %s should have been imported in " + "PmsRoomTypeImporter._import_dependencies" % (record["id"],) + ) + return { + "by_default": record["default"] != 0, + "pms_board_service_id": board_service.id, + } diff --git a/connector_pms_wubook/models/pms_room_type_class/__init__.py b/connector_pms_wubook/models/pms_room_type_class/__init__.py new file mode 100644 index 00000000000..634fde880b8 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_class/__init__.py @@ -0,0 +1,12 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from . import adapter +from . import binder +from . import binding +from . import exporter +from . import importer +from . import mapper_export +from . import mapper_import +from . import pms_room_type_class diff --git a/connector_pms_wubook/models/pms_room_type_class/adapter.py b/connector_pms_wubook/models/pms_room_type_class/adapter.py new file mode 100644 index 00000000000..6c7742497c9 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_class/adapter.py @@ -0,0 +1,77 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _ + +from odoo.addons.component.core import Component +from odoo.addons.connector_pms.components.adapter import ChannelAdapterError + + +class ChannelWubookPmsRoomTypeClassAdapter(Component): + _name = "channel.wubook.pms.room.type.class.adapter" + _inherit = "channel.wubook.adapter" + + _apply_on = "channel.wubook.pms.room.type.class" + + # CRUD + # pylint: disable=W8106 + def create(self, values): + raise ChannelAdapterError( + _( + "Create operation is not supported on Room Class by Wubook. Values: %s. " + "Probably the cause is a wrong mapping of Room Classes on Wubook backend type" + % (values,) + ) + ) + + def read(self, _id): + values = self.search_read([("id", "=", _id)]) + if not values: + raise ChannelAdapterError(_("No Room Type Class found with id '%s'") % _id) + if len(values) != 1: + raise ChannelAdapterError( + _("Received more than one class room %s") % (values,) + ) + return values[0] + + def search_read(self, domain): + values = self._gen_values() + return self._filter(values, domain) + + def search(self, domain): + values = self.search_read(domain) + ids = [x[self._id] for x in values] + return ids + + # pylint: disable=W8106 + def write(self, _id, values): + raise ChannelAdapterError( + _( + "Write operation is not supported on Room Class by Wubook. Id: %i, Values: %s. " + "Probably the cause is a wrong mapping of Room Classes on Wubook backend type" + % (_id, values) + ) + ) + + def delete(self, _id): + raise ChannelAdapterError( + _( + "Delete operation is not supported on Room Class Service by Wubook. Id: %i" + % (_id,) + ) + ) + + def _gen_values(self): + backend_type = self.backend_record.backend_type_id.child_id + names = dict( + backend_type.room_type_class_ids.fields_get( + ["wubook_room_type"], ["selection"] + )["wubook_room_type"]["selection"] + ) + return [ + { + "id": int(x.wubook_room_type), + "name": names[x.wubook_room_type], + "shortname": x.room_type_shortname, + } + for x in backend_type.room_type_class_ids + ] diff --git a/connector_pms_wubook/models/pms_room_type_class/binder.py b/connector_pms_wubook/models/pms_room_type_class/binder.py new file mode 100644 index 00000000000..8e83c36a343 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_class/binder.py @@ -0,0 +1,49 @@ +# 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 Component + + +class ChannelWubookPmsRoomTypeClassBinder(Component): + _name = "channel.wubook.pms.room.type.class.binder" + _inherit = "channel.wubook.binder" + + _apply_on = "channel.wubook.pms.room.type.class" + + _internal_alt_id = ("default_code", "pms_property_ids") + _external_alt_id = "shortname" + + def _get_internal_record_alt(self, model_name, values): + pms_property_id = values["pms_property_ids"][0][1] + records = self.env[model_name].search( + [ + "&", + ("default_code", "=", values["default_code"]), + "|", + ("pms_property_ids", "in", pms_property_id), + ("pms_property_ids", "=", False), + ] + ) + + res, res_priority = self.env[model_name], -1 + for rec in records: + priority = ( + rec.pms_property_ids + and pms_property_id in rec.pms_property_ids.ids + and 1 + ) or 0 + if priority > res_priority: + res, res_priority = rec, priority + elif priority == res_priority: + raise ValidationError( + _( + "Integrity error: There's more than one room type " + "with same code and properties" + ) + ) + + return res + + # TODO: almost identical code as on room type and board services -> Unify diff --git a/connector_pms_wubook/models/pms_room_type_class/binding.py b/connector_pms_wubook/models/pms_room_type_class/binding.py new file mode 100644 index 00000000000..585e43dc14a --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_class/binding.py @@ -0,0 +1,18 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ChannelWubookPmsRoomTypeClassBinding(models.Model): + _name = "channel.wubook.pms.room.type.class" + _inherit = "channel.wubook.binding" + _inherits = {"pms.room.type.class": "odoo_id"} + + # binding fields + odoo_id = fields.Many2one( + comodel_name="pms.room.type.class", + string="Odoo ID", + required=True, + ondelete="cascade", + ) diff --git a/connector_pms_wubook/models/pms_room_type_class/exporter.py b/connector_pms_wubook/models/pms_room_type_class/exporter.py new file mode 100644 index 00000000000..3f031bfbe0e --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_class/exporter.py @@ -0,0 +1,26 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsRoomTypeClassDelayedBatchExporter(Component): + _name = "channel.wubook.pms.room.type.class.delayed.batch.exporter" + _inherit = "channel.wubook.delayed.batch.exporter" + + _apply_on = "channel.wubook.pms.room.type.class" + + +class ChannelWubookPmsRoomTypeClassDirectBatchExporter(Component): + _name = "channel.wubook.pms.room.type.class.direct.batch.exporter" + _inherit = "channel.wubook.direct.batch.exporter" + + _apply_on = "channel.wubook.pms.room.type.class" + + +class ChannelWubookPmsRoomTypeClassExporter(Component): + _name = "channel.wubook.pms.room.type.class.exporter" + _inherit = "channel.wubook.exporter" + + _apply_on = "channel.wubook.pms.room.type.class" diff --git a/connector_pms_wubook/models/pms_room_type_class/importer.py b/connector_pms_wubook/models/pms_room_type_class/importer.py new file mode 100644 index 00000000000..c8daf81b9ce --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_class/importer.py @@ -0,0 +1,26 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsRoomTypeClassDelayedBatchImporter(Component): + _name = "channel.wubook.pms.room.type.class.delayed.batch.importer" + _inherit = "channel.wubook.delayed.batch.importer" + + _apply_on = "channel.wubook.pms.room.type.class" + + +class ChannelWubookPmsRoomTypeClassDirectBatchImporter(Component): + _name = "channel.wubook.pms.room.type.class.direct.batch.importer" + _inherit = "channel.wubook.direct.batch.importer" + + _apply_on = "channel.wubook.pms.room.type.class" + + +class ChannelWubookPmsRoomTypeClassImporter(Component): + _name = "channel.wubook.pms.room.type.class.importer" + _inherit = "channel.wubook.importer" + + _apply_on = "channel.wubook.pms.room.type.class" diff --git a/connector_pms_wubook/models/pms_room_type_class/mapper_export.py b/connector_pms_wubook/models/pms_room_type_class/mapper_export.py new file mode 100644 index 00000000000..9375659381d --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_class/mapper_export.py @@ -0,0 +1,17 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookPmsRoomTypeClassMapperExport(Component): + _name = "channel.wubook.pms.room.type.class.mapper.export" + _inherit = "channel.wubook.mapper.export" + + _apply_on = "channel.wubook.pms.room.type.class" + + direct = [ + ("name", "name"), + ("default_code", "shortname"), + ] diff --git a/connector_pms_wubook/models/pms_room_type_class/mapper_import.py b/connector_pms_wubook/models/pms_room_type_class/mapper_import.py new file mode 100644 index 00000000000..553ad59432f --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_class/mapper_import.py @@ -0,0 +1,32 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping, only_create + + +class ChannelWubookPmsRoomTypeClassMapperImport(Component): + _name = "channel.wubook.pms.room.type.class.mapper.import" + _inherit = "channel.wubook.mapper.import" + + _apply_on = "channel.wubook.pms.room.type.class" + + direct = [ + ("name", "name"), + ("shortname", "default_code"), + ] + + @only_create + @mapping + def backend_id(self, record): + return {"backend_id": self.backend_record.id} + + @mapping + def property_ids(self, record): + binding = self.options.get("binding") + has_pms_properties = binding and bool(binding.pms_property_ids) + if self.options.for_create or has_pms_properties: + return { + "pms_property_ids": [(4, self.backend_record.pms_property_id.id, 0)] + } diff --git a/connector_pms_wubook/models/pms_room_type_class/pms_room_type_class.py b/connector_pms_wubook/models/pms_room_type_class/pms_room_type_class.py new file mode 100644 index 00000000000..8aef14a8e55 --- /dev/null +++ b/connector_pms_wubook/models/pms_room_type_class/pms_room_type_class.py @@ -0,0 +1,14 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PmsRoomTypeClass(models.Model): + _inherit = "pms.room.type.class" + + channel_wubook_bind_ids = fields.One2many( + comodel_name="channel.wubook.pms.room.type.class", + inverse_name="odoo_id", + string="Channel Wubook PMS Bindings", + ) diff --git a/connector_pms_wubook/models/product_pricelist/__init__.py b/connector_pms_wubook/models/product_pricelist/__init__.py new file mode 100644 index 00000000000..bb8eea34483 --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist/__init__.py @@ -0,0 +1,12 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from . import adapter +from . import binder +from . import binding +from . import exporter +from . import importer +from . import mapper_export +from . import mapper_import +from . import product_pricelist diff --git a/connector_pms_wubook/models/product_pricelist/adapter.py b/connector_pms_wubook/models/product_pricelist/adapter.py new file mode 100644 index 00000000000..d1527f305ae --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist/adapter.py @@ -0,0 +1,228 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime + +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import Component +from odoo.addons.connector_pms.components.adapter import ChannelAdapterError + + +class ChannelWubookProductPricelistAdapter(Component): + _name = "channel.wubook.product.pricelist.adapter" + _inherit = "channel.wubook.adapter" + + _apply_on = "channel.wubook.product.pricelist" + + # CRUD + # pylint: disable=W8106 + def create(self, values): + # TODO: share common code from write method and availability plan + # https://tdocs.wubook.net/wired/prices.html#add_vplan + # https://tdocs.wubook.net/wired/prices.html#add_pricing_plan + # https://tdocs.wubook.net/wired/prices.html#update_plan_prices + # pricelist values + if values.get("daily") == 0: + raise ValidationError(_("Intensive plans not supported")) + params = self._prepare_parameters( + {k: values[k] for k in values if k in {"name", "daily"}}, + ["name"], + ["daily"], + ) + _id = self._exec("add_pricing_plan", *params) + + # pricelist item values + items = values.get("items") + if items: + try: + types = {x["type"] for x in items} + if types == {"pricelist"}: + if len(items) != 1: + raise ValidationError( + _("Only one item of type 'pricelist' allowed") + ) + params = self._prepare_parameters( + { + "name": "v%s" % values["name"], + "vpid": _id, + **{k: items[0][k] for k in {"variation_type", "variation"}}, + }, + ["name", "vpid", "variation_type", "variation"], + ) + self._exec("add_vplan", *params) + elif types == {"room"}: + prices = {} + dfrom = None + # TODO: allow gaps between dates grouping rooms + # and making multiple call to webservice + for i, room in enumerate(sorted(items, lambda x: x["date"])): + if not isinstance(room["date"], datetime.date): + raise ValidationError( + _("Date fields must be of type date, not %s") + % type(room["date"]) + ) + if not dfrom: + dfrom = room["date"] + else: + if dfrom + datetime.timedelta(days=i) != room["date"]: + raise ValidationError(_("There's gaps between dates")) + prices.setdefault(str(room["rid"]), []).append(room["price"]) + params = self._prepare_parameters( + { + "id": _id, + "dfrom": dfrom.strftime(self._date_format), + "prices": prices, + }, + ["id", "dfrom", "prices"], + ) + self._exec("update_plan_prices", *params) + else: + raise ValidationError( + _("Type %s not valid, only 'room' and 'pricelist' supported") + % types + ) + except ChannelAdapterError: + self.delete(_id) + raise + + return _id + + def read(self, _id): + # https://tdocs.wubook.net/wired/prices.html#get_pricing_plans + values = self.search_read([("id", "=", _id)]) + if not values: + return False + if len(values) != 1: + raise ChannelAdapterError(_("Received more than one room %s") % (values,)) + return values[0] + + def search_read(self, domain): + # self._check_supported_domain_format(domain) + # https://tdocs.wubook.net/wired/prices.html#get_pricing_plans + all_plans = self._exec("get_pricing_plans") + real_pl_domain, common_pl_domain = self._extract_domain_clauses( + domain, ["date", "rooms"] + ) + base_plans = self._filter(all_plans, common_pl_domain) + res = [] + for plan in base_plans: + values = {x: plan[x] for x in ["id", "name", "daily"]} + if values.get("daily") == 0: + continue + if "vpid" in plan: + values["items"] = [ + { + "type": "pricelist", + **{x: plan[x] for x in {"vpid", "variation", "variation_type"}}, + } + ] + else: + if real_pl_domain: + kw_params = self._domain_to_normalized_dict(real_pl_domain, "date") + kw_params["id"] = plan["id"] + params = self._prepare_parameters( + kw_params, ["id", "date_from", "date_to"], ["rooms"] + ) + date_from = datetime.datetime.strptime( + kw_params["date_from"], self._date_format + ).date() + items_raw = self._exec("fetch_plan_prices", *params) + items = [] + for rid, prices in items_raw.items(): + for i, price in enumerate(prices): + items.append( + { + "type": "room", + "rid": int(rid), + "date": date_from + datetime.timedelta(days=i), + "price": price, + } + ) + values["items"] = items + res.append(values) + return res + + def search(self, domain): + # https://tdocs.wubook.net/wired/prices.html#get_pricing_plans + values = self.search_read(domain) + ids = [x[self._id] for x in values] + return ids + + # pylint: disable=W8106 + def write(self, _id, values): + # TODO: share common code from create method and availability plan + # https://tdocs.wubook.net/wired/prices.html#update_plan_name + # https://tdocs.wubook.net/wired/prices.html#mod_vplans + # https://tdocs.wubook.net/wired/prices.html#update_plan_prices + # pricelist values + if "name" in values: + params = self._prepare_parameters( + {"id": _id, **{k: values[k] for k in values if k in {"name"}}}, + ["id", "name"], + ) + self._exec("update_plan_name", *params) + + # pricelist item values + items = values.get("items") + if items: + types = {x["type"] for x in items} + if types == {"pricelist"}: + if len(items) != 1: + raise ValidationError( + _("Only one item of type 'pricelist' allowed") + ) + params = self._prepare_parameters( + { + "plans": [ + { + "pid": _id, + **{ + k: items[0][k] + for k in {"variation_type", "variation"} + }, + } + ] + }, + ["plans"], + ) + self._exec("mod_vplans", *params) + elif types == {"room"}: + prices = {} + dfrom = None + for i, room in enumerate(sorted(items, key=lambda x: x["date"])): + if not isinstance(room["date"], datetime.date): + raise ValidationError( + _("Date fields must be of type date, not %s") + % type(room["date"]) + ) + if not dfrom: + dfrom = room["date"] + else: + if dfrom + datetime.timedelta(days=i) != room["date"]: + raise ValidationError(_("There's gaps between dates")) + prices.setdefault(str(room["rid"]), []).append(room["price"]) + params = self._prepare_parameters( + { + "id": _id, + "dfrom": dfrom.strftime(self._date_format), + "prices": prices, + }, + ["id", "dfrom", "prices"], + ) + self._exec("update_plan_prices", *params) + else: + raise ValidationError( + _("Type %s not valid, only 'room' and 'pricelist' supported") + % types + ) + + def delete(self, _id, cascade=False): + # TODO: optimize + # https://tdocs.wubook.net/wired/prices.html#del_plan + if cascade: + res = self.search_read([]) + for pl in res: + if pl.get("items", {}).get("vpid") == _id: + self.delete(pl["id"], cascade=False) + self._exec("del_plan", _id) diff --git a/connector_pms_wubook/models/product_pricelist/binder.py b/connector_pms_wubook/models/product_pricelist/binder.py new file mode 100644 index 00000000000..721b78c59f3 --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist/binder.py @@ -0,0 +1,20 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class ChannelWubookProductPricelistBinder(Component): + _name = "channel.wubook.product.pricelist.binder" + _inherit = "channel.wubook.binder" + + _apply_on = "channel.wubook.product.pricelist" + + _internal_alt_id = "name" + _external_alt_id = "name" + + # def _get_internal_record_alt(self, model_name, values): + # pass + + # TODO: find pricelist by name to link to backend when there's + # no binding diff --git a/connector_pms_wubook/models/product_pricelist/binding.py b/connector_pms_wubook/models/product_pricelist/binding.py new file mode 100644 index 00000000000..9b5db39f935 --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist/binding.py @@ -0,0 +1,123 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class ChannelWubookProductPriceBinding(models.Model): + _name = "channel.wubook.product.pricelist" + _inherit = "channel.wubook.binding" + _inherits = {"product.pricelist": "odoo_id"} + + # binding fields + odoo_id = fields.Many2one( + comodel_name="product.pricelist", + string="Odoo ID", + required=True, + ondelete="cascade", + ) + + @api.model + def import_data( + self, + backend_id, + date_from, + date_to, + pricelist_ids, + room_type_ids, + delayed=False, + ): + """ Prepare the batch import of Pricelists from Channel """ + domain = [] + if date_from and date_to: + domain += [ + ("date", ">=", date_from), + ("date", "<=", date_to), + ] + # TODO: duplicated code, unify + if pricelist_ids: + with backend_id.work_on(self._name) as work: + binder = work.component(usage="binder") + external_ids = [] + for pl in pricelist_ids: + binding = binder.wrap_record(pl) + if not binding or not binding.external_id: + raise NotImplementedError( + _( + "The pricelist %s has no binding. Import of Odoo records " + "without binding is not supported yet" + ) + % pl.name + ) + external_ids.append(binding.external_id) + domain.append(("id", "in", external_ids)) + if room_type_ids: + with backend_id.work_on("channel.wubook.pms.room.type") as work: + binder = work.component(usage="binder") + external_ids = [] + for rt in room_type_ids: + binding = binder.wrap_record(rt) + if not binding or not binding.external_id: + raise NotImplementedError( + _( + "The Room type %s has no binding. Import of Odoo records " + "without binding is not supported yet" + ) + % rt.name + ) + external_ids.append(binding.external_id) + domain.append(("rooms", "in", external_ids)) + return self.import_batch( + backend_record=backend_id, domain=domain, delayed=delayed + ) + + @api.model + def export_data(self, backend_record=None): + """ Prepare the batch export of Pricelist to Channel """ + return self.export_batch( + backend_record=backend_record, + domain=[ + ("pms_property_ids", "in", backend_record.pms_property_id.ids), + # ("name", "=", "test101"), + ], + ) + + def resync_import(self): + with self.backend_id.work_on(self._name) as work: + binder = work.component(usage="binder") + pricelist = binder.unwrap_binding(self) + for record in self: + room_type_items = record.item_ids.filtered( + lambda x: x.applied_on == "0_product_variant" + and set(x.pms_property_ids.ids) + == set(self.backend_id.pms_property_id.ids) + ) + if room_type_items: + date_from = min(room_type_items.mapped("date_start")) + date_to = max(room_type_items.mapped("date_end")) + products = room_type_items.mapped("product_id") + room_types = self.env["pms.room.type"].search( + [ + ("product_id", "in", products.ids), + ] + ) + record.import_data( + self.backend_id, + date_from, + date_to, + pricelist, + room_types, + delayed=False, + ) + + def write(self, values): + # workaround to surpass an Odoo bug in a delegation inheritance + # of product.pricelist that does not let to write 'name' field + # if 'items_ids' is written as well on the same write call. + # With other fields like 'sequence' it does not crash but it does not + # save the value entered. For other fields it works but it's unstable. + item_ids = values.pop("item_ids", None) + if item_ids: + super(ChannelWubookProductPriceBinding, self).write({"item_ids": item_ids}) + if values: + return super(ChannelWubookProductPriceBinding, self).write(values) diff --git a/connector_pms_wubook/models/product_pricelist/exporter.py b/connector_pms_wubook/models/product_pricelist/exporter.py new file mode 100644 index 00000000000..8b9ca88d167 --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist/exporter.py @@ -0,0 +1,26 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + + +class ChannelWubookProductPricelistDelayedBatchExporter(Component): + _name = "channel.wubook.product.pricelist.delayed.batch.exporter" + _inherit = "channel.wubook.delayed.batch.exporter" + + _apply_on = "channel.wubook.product.pricelist" + + +class ChannelWubookProductPricelistDirectBatchExporter(Component): + _name = "channel.wubook.product.pricelist.direct.batch.exporter" + _inherit = "channel.wubook.direct.batch.exporter" + + _apply_on = "channel.wubook.product.pricelist" + + +class ChannelWubookProductPricelistExporter(Component): + _name = "channel.wubook.product.pricelist.exporter" + _inherit = "channel.wubook.exporter" + + _apply_on = "channel.wubook.product.pricelist" diff --git a/connector_pms_wubook/models/product_pricelist/importer.py b/connector_pms_wubook/models/product_pricelist/importer.py new file mode 100644 index 00000000000..c4d3f19d54e --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist/importer.py @@ -0,0 +1,41 @@ +# 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 Component + + +class ChannelWubookProductPricelistDelayedBatchImporter(Component): + _name = "channel.wubook.product.pricelist.delayed.batch.importer" + _inherit = "channel.wubook.delayed.batch.importer" + + _apply_on = "channel.wubook.product.pricelist" + + +class ChannelWubookProductPricelistDirectBatchImporter(Component): + _name = "channel.wubook.product.pricelist.direct.batch.importer" + _inherit = "channel.wubook.direct.batch.importer" + + _apply_on = "channel.wubook.product.pricelist" + + +class ChannelWubookProductPricelistImporter(Component): + _name = "channel.wubook.product.pricelist.importer" + _inherit = "channel.wubook.importer" + + _apply_on = "channel.wubook.product.pricelist" + + def _import_dependencies(self, external_data, external_fields): + # if not external_fields or 'items' in external_fields: + vpids, rids = set(), set() + for it in external_data.get("items", []): + if it["type"] == "pricelist": + vpids.add(it["vpid"]) + elif it["type"] == "room": + rids.add(it["rid"]) + else: + raise ValidationError(_("Pricelist type %s not valid") % it["type"]) + self._import_dependency(vpids, "channel.wubook.product.pricelist") + self._import_dependency(rids, "channel.wubook.pms.room.type") diff --git a/connector_pms_wubook/models/product_pricelist/mapper_export.py b/connector_pms_wubook/models/product_pricelist/mapper_export.py new file mode 100644 index 00000000000..605aac0ab7d --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist/mapper_export.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 Component +from odoo.addons.connector.components.mapper import mapping + + +class ChannelWubookProductPricelistMapperExport(Component): + _name = "channel.wubook.product.pricelist.mapper.export" + _inherit = "channel.wubook.mapper.export" + + _apply_on = "channel.wubook.product.pricelist" + + direct = [ + ("name", "name"), + ] + + children = [("item_ids", "items", "channel.wubook.product.pricelist.item")] + + @mapping + def pricelist_type(self, record): + return {"pricelist_type": "daily"} + + +class ChannelWubookProductPricelistChildMapperExport(Component): + _name = "channel.wubook.product.pricelist.child.mapper.export" + _inherit = "channel.wubook.child.mapper.export" + + _apply_on = "channel.wubook.product.pricelist.item" diff --git a/connector_pms_wubook/models/product_pricelist/mapper_import.py b/connector_pms_wubook/models/product_pricelist/mapper_import.py new file mode 100644 index 00000000000..d5bbe3c5a88 --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist/mapper_import.py @@ -0,0 +1,101 @@ +# 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 Component +from odoo.addons.connector.components.mapper import mapping, only_create + + +class ChannelWubookProductPricelistMapperImport(Component): + _name = "channel.wubook.product.pricelist.mapper.import" + _inherit = "channel.wubook.mapper.import" + + _apply_on = "channel.wubook.product.pricelist" + + direct = [ + ("name", "name"), + ] + + children = [("items", "item_ids", "channel.wubook.product.pricelist.item")] + + @only_create + @mapping + def backend_id(self, record): + return {"backend_id": self.backend_record.id} + + @mapping + def pricelist_type(self, record): + return {"pricelist_type": "daily"} + + @mapping + def pms_property_ids(self, record): + return {"pms_property_ids": [(6, 0, [self.backend_record.pms_property_id.id])]} + + +class ChannelWubookProductPricelistChildMapperImport(Component): + _name = "channel.wubook.product.pricelist.child.mapper.import" + _inherit = "channel.wubook.child.mapper.import" + _apply_on = "channel.wubook.product.pricelist.item" + + def get_item_values(self, map_record, to_attr, options): + values = super().get_item_values(map_record, to_attr, options) + binding = options.get("binding") + if binding: + applied_on = values.get("applied_on") + if applied_on == "3_global": + item_ids = binding.item_ids.filtered( + lambda x: all( + [ + x.applied_on == applied_on, + x.compute_price == values["compute_price"], + x.base == values["base"], + x.base_pricelist_id.id == values["base_pricelist_id"], + set(x.pms_property_ids.ids) + == set(values["pms_property_ids"][0][2]), + ] + ) + ) + elif applied_on == "0_product_variant": + item_ids = binding.item_ids.filtered( + lambda x: all( + [ + x.applied_on == applied_on, + x.compute_price == values["compute_price"], + x.product_id.id == values["product_id"], + x.date_start == values["date_start"], + x.date_end == values["date_end"], + set(x.pms_property_ids.ids) + == set(values["pms_property_ids"][0][2]), + ] + ) + ) + else: + raise ValidationError(_("Unexpected pricelist type '%s'") % applied_on) + + if item_ids: + if len(item_ids) > 1: + raise ValidationError( + _( + "Found two pricelist items with same properties %s. " + "Please remove one of them" + ) + % values + ) + values["id"] = item_ids.id + + return values + + def format_items(self, items_values): + ops = [] + items_values = sorted( + items_values, key=lambda x: (x["product_id"], x["date_start"]), reverse=True + ) + for values in items_values: + _id = values.pop("id", None) + if _id: + ops.append((1, _id, values)) + else: + ops.append((0, 0, values)) + + return ops diff --git a/connector_pms_wubook/models/product_pricelist/product_pricelist.py b/connector_pms_wubook/models/product_pricelist/product_pricelist.py new file mode 100644 index 00000000000..92d2f8a9547 --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist/product_pricelist.py @@ -0,0 +1,14 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ProductPricelist(models.Model): + _inherit = "product.pricelist" + + channel_wubook_bind_ids = fields.One2many( + comodel_name="channel.wubook.product.pricelist", + inverse_name="odoo_id", + string="Channel Wubook PMS Bindings", + ) diff --git a/connector_pms_wubook/models/product_pricelist_item/__init__.py b/connector_pms_wubook/models/product_pricelist_item/__init__.py new file mode 100644 index 00000000000..8e1f10bb048 --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist_item/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from . import binding +from . import mapper_export +from . import mapper_import diff --git a/connector_pms_wubook/models/product_pricelist_item/binding.py b/connector_pms_wubook/models/product_pricelist_item/binding.py new file mode 100644 index 00000000000..5d86a663b2c --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist_item/binding.py @@ -0,0 +1,17 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ChannelWubookProductPricelistItemBinding(models.Model): + _name = "channel.wubook.product.pricelist.item" + _inherit = "channel.wubook.binding" + _inherits = {"product.pricelist.item": "odoo_id"} + + odoo_id = fields.Many2one( + comodel_name="product.pricelist.item", + string="Odoo ID", + required=True, + ondelete="cascade", + ) diff --git a/connector_pms_wubook/models/product_pricelist_item/mapper_export.py b/connector_pms_wubook/models/product_pricelist_item/mapper_export.py new file mode 100644 index 00000000000..704210b225e --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist_item/mapper_export.py @@ -0,0 +1,22 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo.addons.component.core import Component + +# from odoo.addons.connector.components.mapper import mapping + + +class ChannelWubookProductPricelistItemMapperExport(Component): + _name = "channel.wubook.product.pricelist.item.mapper.export" + _inherit = "channel.wubook.mapper.export" + + _apply_on = "channel.wubook.product.pricelist.item" + + # direct = [ + # ("name", "name"), + # ] + # + # @mapping + # def name(self, record): + # return {"name": record['name']} diff --git a/connector_pms_wubook/models/product_pricelist_item/mapper_import.py b/connector_pms_wubook/models/product_pricelist_item/mapper_import.py new file mode 100644 index 00000000000..fd765e87641 --- /dev/null +++ b/connector_pms_wubook/models/product_pricelist_item/mapper_import.py @@ -0,0 +1,92 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime + +import pytz + +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping + + +class ChannelWubookProductPricelistItemMapperImport(Component): + _name = "channel.wubook.product.pricelist.item.mapper.import" + _inherit = "channel.wubook.mapper.import" + + _apply_on = "channel.wubook.product.pricelist.item" + + @mapping + def pms_property_id(self, record): + return {"pms_property_ids": [(6, 0, self.backend_record.pms_property_id.ids)]} + + @mapping + def items(self, record): + # TODO: move this helper to a common lib or parent + def to_naive_utc(dt): + dt = datetime.datetime(*dt.timetuple()[:3]) + dt = pytz.timezone(pms_property_id.tz).localize(dt) + dt = dt.astimezone(pytz.utc) + dt = dt.replace(tzinfo=None) + return dt + + ttype = record["type"] + if ttype == "pricelist": + pl_binder = self.binder_for("channel.wubook.product.pricelist") + pricelist = pl_binder.to_internal(record["vpid"], unwrap=True) + if not pricelist: + raise ValidationError( + _( + "External record with id %i not exists. " + "It should be imported in _import_dependencies" + ) + % record["vpid"] + ) + values = { + "applied_on": "3_global", + "compute_price": "formula", + "base": "pricelist", + "base_pricelist_id": pricelist.id, + } + + variation_type = record["variation_type"] + variation = record["variation"] + if variation_type == -2: + values["price_discount"] = 0 + values["price_surcharge"] = -variation + elif variation_type == -1: + values["price_discount"] = variation + values["price_surcharge"] = 0 + elif variation_type == 1: + values["price_discount"] = -variation + values["price_surcharge"] = 0 + elif variation_type == 2: + values["price_discount"] = 0 + values["price_surcharge"] = variation + else: + raise ValidationError(_("Unknown variation type %s") % variation_type) + elif ttype == "room": + rt_binder = self.binder_for("channel.wubook.pms.room.type") + room_type = rt_binder.to_internal(record["rid"], unwrap=True) + if not room_type: + raise ValidationError( + _( + "External record with id %i not exists. " + "It should be imported in _import_dependencies" + ) + % record["rid"] + ) + + values = { + "applied_on": "0_product_variant", + "compute_price": "fixed", + "product_id": room_type.product_id.id, + "fixed_price": record["price"], + "date_start": to_naive_utc(record["date"]), + "date_end": to_naive_utc(record["date"]), + } + else: + raise ValidationError(_("Unknown type '%s'") % ttype) + + return values diff --git a/connector_pms_wubook/models/queue_job.py b/connector_pms_wubook/models/queue_job.py new file mode 100644 index 00000000000..a6a7345c3b7 --- /dev/null +++ b/connector_pms_wubook/models/queue_job.py @@ -0,0 +1,44 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class QueueJob(models.Model): + _inherit = "queue.job" + + def wubook_import_record_related_action(self, name): + self.ensure_one() + backend_record, external_id = self.args[:2] + + with backend_record.work_on(self.model_name) as work: + binder = work.component(usage="binder") + relation = binder.to_internal(external_id, unwrap=True) + + action = { + "name": name, + "type": "ir.actions.act_window", + "res_model": relation._name, + "view_type": "form", + "view_mode": "form", + "res_id": relation.id, + } + + return action + + def wubook_export_record_related_action(self, name): + self.ensure_one() + model = self.model_name + partner = self.records + action = { + "name": name, + "type": "ir.actions.act_window", + "res_model": model, + "view_type": "form", + "view_mode": "form", + "res_id": partner.id, + } + return action diff --git a/connector_pms_wubook/readme/CONTRIBUTORS.rst b/connector_pms_wubook/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..a9e48ccda7b --- /dev/null +++ b/connector_pms_wubook/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Eric Antones diff --git a/connector_pms_wubook/readme/DESCRIPTION.rst b/connector_pms_wubook/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..65ba5cef938 --- /dev/null +++ b/connector_pms_wubook/readme/DESCRIPTION.rst @@ -0,0 +1,11 @@ +This module implement Wubook API + +Features: + + * Avaliability Management + * Restrictions Management + * Prices Management + * Rooms Management + * Booking Management + * HTTP Push Controllers + * CRON Jobs diff --git a/connector_pms_wubook/readme/USAGE.rst b/connector_pms_wubook/readme/USAGE.rst new file mode 100644 index 00000000000..127af6f9f79 --- /dev/null +++ b/connector_pms_wubook/readme/USAGE.rst @@ -0,0 +1,18 @@ +Go to connector section and create a new channel backend. Fill form and use buttons to synchronize data: + +#. Create Backend +#. Import OTA's Info +#. Import Rooms +#. Import Restrictions & Price Plans +#. Import Availiability, Restrictions & Price Values +#. Synch Push URL +#. Import Reservations + +** Update PMS settings to use imported plans as default plans + +Avaliability, Restrictions and Pricelist are flagged when change to know when need be uploaded. +All other records modifications are handled to be sent to server as it changes. + +All changes are pushed every 5 minutes... and request new bookings every minute. + +Modifications using calendars are pushed immediately when press "save" button. diff --git a/connector_pms_wubook/security/ir.model.access.csv b/connector_pms_wubook/security/ir.model.access.csv new file mode 100644 index 00000000000..71e6df2e0a5 --- /dev/null +++ b/connector_pms_wubook/security/ir.model.access.csv @@ -0,0 +1,16 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_channel_wubook_backend_manager,channel wubook backend manager,model_channel_wubook_backend,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_backend_type_manager,channel wubook backend type manager,model_channel_wubook_backend_type,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_backend_type_board_service_manager,channel wubook backend type board service manager,model_channel_wubook_backend_type_board_service,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_backend_type_room_type_class_manager,channel wubook backend type room type class manager,model_channel_wubook_backend_type_room_type_class,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_pms_room_type_manager,channel wubook pms room type manager,model_channel_wubook_pms_room_type,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_pms_room_type_class_manager,channel wubook pms room type class manager,model_channel_wubook_pms_room_type_class,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_pms_board_service_manager,channel wubook pms board service manager,model_channel_wubook_pms_board_service,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_product_pricelist_manager,channel wubook product pricelist manager,model_channel_wubook_product_pricelist,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_product_pricelist_item_manager,channel wubook product pricelist item manager,model_channel_wubook_product_pricelist_item,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_pms_availability_plan_manager,channel wubook pms availability plan manager,model_channel_wubook_pms_availability_plan,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_pms_availability_plan_rule_manager,channel wubook pms availability plan rule manager,model_channel_wubook_pms_availability_plan_rule,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_pms_reservation_manager,channel wubook pms reservation manager,model_channel_wubook_pms_reservation,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_pms_reservation_line_manager,channel wubook pms reservation line manager,model_channel_wubook_pms_reservation_line,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_pms_room_type_board_service_manager,channel wubook pms room type board service manager,model_channel_wubook_pms_room_type_board_service,connector.group_connector_manager,1,1,1,1 +access_channel_wubook_pms_folio_manager,channel wubook pms folio manager,model_channel_wubook_pms_folio,connector.group_connector_manager,1,1,1,1 diff --git a/connector_pms_wubook/static/description/icon.png b/connector_pms_wubook/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/connector_pms_wubook/static/description/icon.png differ diff --git a/connector_pms_wubook/static/description/index.html b/connector_pms_wubook/static/description/index.html new file mode 100644 index 00000000000..26f0bd34367 --- /dev/null +++ b/connector_pms_wubook/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_wubook/tests/__init__.py b/connector_pms_wubook/tests/__init__.py new file mode 100644 index 00000000000..b3837d68a71 --- /dev/null +++ b/connector_pms_wubook/tests/__init__.py @@ -0,0 +1,10 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import server +from . import common +from . import test_room_type +from . import test_room_type_class +from . import test_product_pricelist +from . import test_availability_plan +from . import test_folio diff --git a/connector_pms_wubook/tests/common.py b/connector_pms_wubook/tests/common.py new file mode 100644 index 00000000000..1eb21fd40f9 --- /dev/null +++ b/connector_pms_wubook/tests/common.py @@ -0,0 +1,134 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +import json +import logging + +from odoo.addons.component.tests.common import SavepointComponentCase + +_logger = logging.getLogger(__name__) + + +class TestWubookConnector(SavepointComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # disable jobs + # TODO: it breaks the folio creation with error: + # psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint + # "mail_followers_mail_followers_res_partner_res_model_id_uniq" + # DETAIL: Key (res_model, res_id, partner_id)=(pms.folio, 79, 3) already exists. + cls.env = cls.env( + context=dict( + cls.env.context, + test_queue_job_no_delay=True, # no jobs thanks + ) + ) + + # common test data + cls.user1 = lambda self, pms_property: cls.env["res.users"].create( + { + "name": "User backend 1", + "login": "userbackend1", + "company_id": pms_property.company_id.id, + "pms_property_ids": pms_property.ids, + "pms_property_id": pms_property.id, + "groups_id": [ + ( + 6, + 0, + [ + cls.env.ref("pms.group_pms_manager").id, + cls.env.ref("base.group_user").id, + cls.env.ref("connector.group_connector_manager").id, + cls.env.ref("sales_team.group_sale_manager").id, + ], + ) + ], + } + ) + # TODO: make it work with other user than root (__system__) - see setUpClass of TestWubookConnector + cls.user1 = lambda self, pms_property: cls.env.ref("base.user_root") + + backend_type_values1 = { + "name": "Backend Type Test 1", + "model_type_id": cls.env.ref( + "connector_pms_wubook.model_channel_wubook_backend_type" + ).id, + "room_type_class_ids": [ + ( + 0, + 0, + { + "wubook_room_type": "1", # Room + "room_type_shortname": "RO", + }, + ), + ( + 0, + 0, + { + "wubook_room_type": "2", + "room_type_shortname": "AP", + }, + ), + ( + 0, + 0, + { + "wubook_room_type": "4", + "room_type_shortname": "CO", + }, + ), + ], + "board_service_ids": [ + ( + 0, + 0, + { + "wubook_board_service": "ai", + "board_service_shortname": "AI", + }, + ), + ( + 0, + 0, + { + "wubook_board_service": "hb", + "board_service_shortname": "HB", + }, + ), + ( + 0, + 0, + { + "wubook_board_service": "fb", + "board_service_shortname": "FB", + }, + ), + ( + 0, + 0, + { + "wubook_board_service": "bb", + "board_service_shortname": "BB", + }, + ), + ], + } + cls.backend_type1 = cls.env["channel.wubook.backend.type"].create( + backend_type_values1 + ) + cls.fake_credentials = { + "username": "X", + "password": "X", + "property_code": "X", + "pkey": "X", + } + cls.test_credentials = {} + try: + with open("wubook_auth.json", "r") as f: + cls.test_credentials = json.load(f) + except FileNotFoundError: + pass diff --git a/connector_pms_wubook/tests/server.py b/connector_pms_wubook/tests/server.py new file mode 100644 index 00000000000..0bda07e64d2 --- /dev/null +++ b/connector_pms_wubook/tests/server.py @@ -0,0 +1,212 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +import mock + +_logger = logging.getLogger(__name__) + + +class MockWubookServer: + _id = "id" + _meta_fields = {"token", "lcode"} + _unique_keys = { + "pk": [_id], + "uk1": ["shortname"], + } + + def __init__(self): + self.data = {} + + self.all_fields = { + "name": "Test room type r9", + "rtype": 9, + "rtype_name": "Room type 9", + "price": 33.0, + "men": 5, + "subroom": 0, + "occupancy": 5, + "id": 9, + "board": "ai", + "boards": [{"nb": {}, "bb": {}, "fb": {}}], + "availability": 5, + "shortname": "c9", + "min_price": 0, + "max_price": 0, + "children": 0, + "anchorate": 0, + "dec_avail": 0, + "woodoo": 0, + } + + # main methods + def fetch_rooms_mock(self, token, lcode, **kwargs): + if lcode not in self.data: + return -1, "The property '%s' does not exist" % lcode + return 0, list(self.data[lcode].values()) + + def fetch_single_room_mock(self, token, lcode, rcode, **kwargs): + if lcode not in self.data: + return -1, "The property '%s' does not exist" % lcode + if rcode not in self.data[lcode]: + return -2, "The room type '{}' does not exist on property '{}'".format( + rcode, + lcode, + ) + return 0, [self.data[lcode][rcode]] + + def new_room_mock(self, *args): + fields = [ + "token", + "lcode", + "woodoo", + "name", + "occupancy", + "price", + "availability", + "shortname", + "board", + "names", + "descriptions", + "boards", + "rtype", + "min_price", + "max_price", + ] + data = dict(list(zip(fields, args))) + lcode = data["lcode"] + data.update({k: False for k in set(self.all_fields.keys()) - set(fields)}) + for k in self._meta_fields: + del data[k] + self.data.setdefault(lcode, {}) + rid = self.data[lcode] and max(self.data[lcode].keys()) + 1 or 1 + data[self._id] = rid + res, info = self._check_unique_keys(lcode, data) + if res: + return res, info + self.data[lcode][rid] = data + return 0, rid + + def mod_room_mock(self, *args, **kwargs): + fields = [ + "token", + "lcode", + "id", + "name", + "occupancy", + "price", + "availability", + "shortname", + "board", + "names", + "descriptions", + "boards", + "min_price", + "max_price", + "rtype", + "woodoo", + ] + data = dict(list(zip(fields, args))) + lcode = data["lcode"] + rid = data[self._id] + for k in self._meta_fields: + del data[k] + if lcode not in self.data: + return -1, "The property '%s' does not exist" % lcode + if rid not in self.data[lcode]: + return -2, "The room type '{}' does not exist on property '{}'".format( + rid, + lcode, + ) + res, info = self._check_unique_keys(lcode, data, exclude=True) + if res: + return res, info + self.data[lcode][rid].update(data) + return 0, None + + # aux methods + def _check_unique_keys(self, lcode, data, exclude=False): + dict_unique = {} + for ukn, ukf in self._unique_keys.items(): + dict_unique.setdefault(ukn, tuple()) + dict_unique[ukn] = (tuple(ukf), tuple([data[x] for x in ukf])) + records = self.data[lcode] + if exclude: + records = dict(records) + del records[data[self._id]] + for rec in records.values(): + for ukn, (ukf, ukv) in dict_unique.items(): + if tuple([rec[x] for x in ukf]) == ukv: + return ( + -1, + "Duplicate values on property '%s': unique key '%s' -> '%s'" + % (lcode, ukn, ukv), + ) + return 0, None + + def get_mock(self): + m = mock.Mock( + spec=[ + "acquire_token", + "release_token", + "fetch_rooms", + "fetch_single_room", + "new_room", + "mod_room", + ] + ) + + m.acquire_token.return_value = (0, None) + m.release_token.return_value = (0, None) + + m.fetch_rooms = self.fetch_rooms_mock + m.fetch_single_room = self.fetch_single_room_mock + + m.new_room = self.new_room_mock + m.mod_room = self.mod_room_mock + + return m + + +# class MockWubookRoomTypeClassAdapter: +# _id = "id" +# _uk = [ +# ("shortname",), +# ] +# _all_fields = { +# "name": "Test room type class cl9", +# "shortname": "cl9", +# } +# +# def __init__(self): +# self._data = {} +# self._index_data = {} +# +# # pylint: disable = W8106 +# def create(self, backend_id, values): +# fields = set(values.keys()) +# if self._id in fields: +# raise Exception("The field '%s' cannot be in the values" % self._id) +# valid_fields = set(self._all_fields.keys()) +# if fields - valid_fields: +# raise Exception("There's fields on %s which don't exist" % values) +# +# self._data.setdefault(backend_id, {}) +# self._index_data.setdefault(backend_id, {}) +# +# _id_next = max(self._data[backend_id].keys() or [0]) + 1 +# +# for uk_t in self._uk: +# if set(uk_t).issubset(fields): +# key = tuple([values[x] for x in uk_t]) +# if key in self._index_data[backend_id]: +# raise Exception("UK {} duplicated inserting {}".format(key, values)) +# self._index_data[backend_id][key] = _id_next +# if _id_next in self._data[backend_id]: +# raise Exception( +# "PK {} duplicated inserting {}".format(_id_next, values) +# ) +# self._data[backend_id][_id_next] = values +# +# return _id_next diff --git a/connector_pms_wubook/tests/test_availability_plan.py b/connector_pms_wubook/tests/test_availability_plan.py new file mode 100644 index 00000000000..b3a6eeb15f1 --- /dev/null +++ b/connector_pms_wubook/tests/test_availability_plan.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.tests.common import tagged + +from . import common + +_logger = logging.getLogger(__name__) + + +@tagged("test_debug") +class TestPmsAvailabilityPlan(common.TestWubookConnector): + def test_availability_plan_01(self): + # ARRANGE + property1 = self.browse_ref("pms.main_pms_property") + + backend1 = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "user_id": self.user1(property1).id, + "pms_property_id": property1.id, + "backend_type_id": self.backend_type1.parent_id.id, + # **self.fake_credentials, + **self.test_credentials, + } + ) + + # with backend1.work_on("channel.wubook.pms.room.type") as work: + # adapter = work.component(usage="backend.adapter") + + # res = adapter.search_read([ + # ('shortname', '=', 'c1') + # ]) + # print("***", res) + + # res = adapter.write(503764, { + # 'name': 'Room type r1', + # 'occupancy': 6, + # 'price': 11.0, + # "availability": 2, + # "shortname": 'c1', + # 'board': 'ai', + # ############# + # "names": False, + # "descriptions": False, + # #"boards": {'nb': {'dtype': 2, 'value': 0}}, + # "boards": {}, + # "min_price": False, + # "max_price": False, + # "rtype": 2, + # "woodoo": 0, + # }) + # + # print("***", res) + # + # return + + # with backend1.work_on("channel.wubook.pms.availability.plan") as work: + # adapter = work.component(usage="backend.adapter") + # + # res = adapter.search_read( + # [ + # ("name", "in", ("test95K", "test90K", "test119", "test109", "test101", "test8", "test4")), + # ("date", ">", datetime.datetime(2021, 6, 1)), + # ("date", "<", datetime.datetime(2021, 6, 5)), + # # ('id_room', 'in', [503764]), + # # ('dfrom', '=', datetime.datetime(2021, 6, 1)), + # # ('dto', '=', datetime.datetime(2021, 6, 5)) + # ] + # ) + # print("**************", res, len(res)) #, len(res)) + + # backend1.plan_date_from = datetime.date(2021, 6, 3) + # backend1.plan_date_to = datetime.date(2021, 6, 10) + # # backend1.plan_room_type_ids = False + # + # backend1.import_availability_plans() + + # backend1.export_availability_plans() + backend1.export_room_types() + + return diff --git a/connector_pms_wubook/tests/test_folio.py b/connector_pms_wubook/tests/test_folio.py new file mode 100644 index 00000000000..423e4c05ed1 --- /dev/null +++ b/connector_pms_wubook/tests/test_folio.py @@ -0,0 +1,604 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime +import json +import logging + +from . import common + +_logger = logging.getLogger(__name__) + + +# @tagged("test_debug") +class TestPmsFolio(common.TestWubookConnector): + def test_folio_01(self): + # ARRANGE + property1 = self.browse_ref("pms.main_pms_property") + + backend1 = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "user_id": self.user1(property1).id, + "pms_property_id": property1.id, + "backend_type_id": self.backend_type1.parent_id.id, + # **self.fake_credentials, + **self.test_credentials, + } + ) + + # with backend1.work_on("channel.wubook.pms.folio") as work: + # adapter = work.component(usage="backend.adapter") + + #### fetch_bookings_codes + # params = adapter._prepare_parameters( + # { + # 'dfrom': '01/05/2021', + # 'dto': '10/06/2021', + # 'oncreated': 0, + # }, + # ['dfrom', 'dto'], ["oncreated"] + # ) + # values = adapter._exec("fetch_bookings_codes", *params) + # print(values) + # with open("wubook_fetch_bookings_codes.json", "w") as f: + # json.dump(values, f) + # return + + ### fetch_bookings + # params = adapter._prepare_parameters( + # { + # 'dfrom': '01/05/2021', + # 'dto': '10/06/2021', + # 'oncreated': 0, + # }, + # [], ['dfrom', 'dto', "oncreated", 'ancillary'] + # ) + # values = adapter._exec("fetch_bookings", *params) + # print(values) + # with open("wubook_fetch_bookings.json", "w") as f: + # json.dump(values, f) + # return + + ### fetch_booking + # params = adapter._prepare_parameters( + # { + # 'rcode': '1618172780', + # }, + # ['rcode'], ['ancillary'] + # ) + # values = adapter._exec("fetch_booking", *params) + # print(values) + # with open("wubook_fetch_booking.json", "w") as f: + # json.dump(values, f) + # return + + ### fetch_new_bookings + # params = adapter._prepare_parameters( + # { + # 'mark': 0, + # }, + # [],['ancillary', 'mark'] + # ) + # values = adapter._exec("fetch_new_bookings", *params) + # print(values) + # with open("wubook_fetch_new_bookings.json", "w") as f: + # json.dump(values, f) + # return + + ### mark_bookings + # return + + # with backend1.work_on("channel.wubook.pms.room.type.class") as work: + # adapter = work.component(usage="backend.adapter") + # + # f = adapter.search_read([]) + # + # print("---------", f) + # return + + # with backend.work_on("channel.wubook.pms.room.type") as work: + # adapter = work.component(usage="backend.adapter") + + # res = adapter.read(501773) + # print(res, len(res)) + + # res = adapter.search_read([ + # #('rtype', 'in', [1, 3]) + # ('id', '=', 501773) + # #('id', '!=', 501773) + # #('shortname', '=', 'TRP') + # ]) + # print(res, len(res)) + # + # return + + backend1.folio_date_arrival_from = datetime.date(2021, 6, 3) + backend1.folio_date_arrival_to = datetime.date(2021, 6, 10) + backend1.folio_mark = False + + backend1.import_folios() + + return + # + # # self.env.cr.commit() + # + # return + + # folio = self.env["pms.folio"].create( + # { + # #"pricelist_id": record.pricelist_id.id, + # #"partner_id": record.partner_id.id, + # #"pms_property_id": record.pms_property_id.id, + # 'mobile': 'yyyyyyyyyyyyyyyy', + # 'reservation_ids': [ + # (0, 0, { + # 'checkin': datetime.datetime(2021, 4, 25), + # 'checkout': datetime.datetime(2021, 4, 29), + # "room_type_id": self.env.ref('pms.pms_room_type_3').id, + # #"partner_id": record.partner_id.id, + # #"pricelist_id": record.pricelist_id.id, + # #"pms_property_id": folio.pms_property_id.id, + # }), + # (0, 0, { + # 'checkin': datetime.datetime(2021, 4, 25), + # 'checkout': datetime.datetime(2021, 4, 29), + # "room_type_id": self.env.ref('pms.pms_room_type_1').id, + # # "partner_id": record.partner_id.id, + # # "pricelist_id": record.pricelist_id.id, + # # "pms_property_id": folio.pms_property_id.id, + # }) + # ] + # } + # ) + # + # + # print("---------------", f) + # self.env.cr.commit() + # return + # ----------------------------------- + + # self.env["channel.wubook.pms.room.type.class"].import_data(backend) + # self.env["channel.wubook.pms.room.type"].import_data(backend) + + # self.env["channel.wubook.pms.room.type"].import_data(backend, + # # datetime.date(2021,2,14), datetime.date(2021,2,15), None, None) + # with open("wubook_reservation.json", 'r') as f: + # a = json.load(f) + + # print(a) + + with backend1.work_on("channel.wubook.pms.folio") as work: + adapter = work.component(usage="backend.adapter") + + res = adapter.search_read( + [ + ("date_arrival", ">", datetime.date(2021, 6, 3)), + ("date_arrival", "<", datetime.date(2021, 6, 10)), + # ('mark', '=', True), + # ('men', '>=', 17), + # ('men', '<', 120), + ] + ) + + for r in res: + print(" ****************************+") + # b = {x:r[x] for x in ('date_received', 'date_arrival', 'date_departure')} + # print(b) + for k, v in r.items(): + print(" {}: {}".format(k, v)) + print("-----------", len(res)) + + def conv(v): + if isinstance(v, datetime.date): + return v.strftime("%d/%m/%Y") + + with open("wubook_folio.json", "w") as f: + json.dump(res, f, default=conv) + + return + + # res = adapter.search_read( + # [ + # ("arrival_dfrom", "=", datetime.datetime(2021, 3, 23)), + # ("arrival_dto", "=", datetime.datetime(2021, 3, 24)), + # # ('rooms', '=', '477968'), + # ] + # ) + # print(res, len(res)) + + # res = adapter.search_read([ + # ('reservation_code', 'in', [1616404933,1614274186]), + # ]) + # print(res, len(res)) + + # res = adapter.create({'name': 'test8'}) + + # res = adapter.search_read([('id', '=', 85448)]) + # res = adapter.search_read([('id', 'in', (85448,85447,))]) + # res = adapter.search_read([]) + # for r in res: + # adapter.delete(r['id']) + # print(r) + + # res = adapter.search_read([ + # # ('id', '=', 85448), + # ('dfrom', '=', datetime.date(2021, 3, 25)), + # ('dto', '=', datetime.date(2021, 3, 27)) + # ]) + # print("vvvvvvvvvvvvvvv", res) + + # res = adapter.create({ + # 'name': 'test119', + # 'items': [ + # { + # 'id_room': 478489, + # 'date': datetime.date(2021, 3, 25), + # 'closed': 1, + # 'max_stay': 5, + # 'min_stay_arrival': 7, + # 'avail': 9, + # } + # ] + # }) + # print(res) + # res = adapter.search_read([ + # # ('id', '=', 85448), + # ('name', '=', 'test119'), + # ('dfrom', '=', datetime.date(2021, 3, 25)), + # ('dto', '=', datetime.date(2021, 3, 27)) + # ]) + # print("vvvvvvvvvvvvvvv", res) + + return + res = adapter.create( + { + "name": "test99K", + "items": [ + { + "id_room": 477968, + "date": datetime.date(2021, 2, 24), + "closed": 1, + "max_stay": 8, + "min_stay_arrival": 9, + }, + { + "id_room": 477968, + "date": datetime.date(2021, 2, 26), + "closed": 1, + "max_stay": 18, + "min_stay_arrival": 89, + }, + { + "id_room": 477968, + "date": datetime.date(2021, 2, 28), + "closed": 1, + "max_stay": 178, + "min_stay_arrival": 879, + }, + { + "id_room": 477968, + "date": datetime.date(2021, 3, 28), + "closed": 0, + "max_stay": 23, + "min_stay_arrival": 9, + }, + ], + } + ) + + print("XXXXXXXXXXXX", res) + + return + res = adapter.write( + 86122, + { + "name": "test101-renamed2", + "items": [ + { + "id_room": 477968, + "date": datetime.date(2021, 3, 24), + "closed": 1, + "max_stay": 8, + "min_stay_arrival": 9, + "no_ota": 1, + "avail": 11, + }, + { + "id_room": 477968, + "date": datetime.date(2021, 3, 26), + "closed": 1, + "max_stay": 18, + "min_stay_arrival": 89, + "avail": 13, + }, + { + "id_room": 477968, + "date": datetime.date(2021, 3, 28), + "closed": 1, + "max_stay": 178, + "min_stay_arrival": 879, + }, + ], + }, + ) + + res = adapter.search_read( + [ + ("id", "=", 86122), + # ('name', '=', 'test119'), + ("dfrom", "=", datetime.date(2021, 3, 24)), + ("dto", "=", datetime.date(2021, 3, 28)), + ] + ) + print("vvvvvvvvvvvvvvv", res) + + return + + # --------------------------------------------- + + # with backend.work_on("channel.wubook.product.pricelist") as work: + # binding_model = work.model + # + # binding_model.import_batch(backend, domain=[('name', '=', 'vtest68')]) + # + # binding_model.import_batch(backend, domain=[('name', '=', 'vtest68')]) + # + # rr self.env["channel.wubook.product.pricelist"].search([]) + # for i in rr + # + # a = 1 + # + # return + + # --------------------------------------------- + + # parent = self.env["channel.wubook.product.pricelist"].create({ + # 'name': 'child', + # 'backend_id': backend.id, + # }) + # print("222222222222222 --------------", parent, parent.name) + # + # parent.write({ + # 'name': 'pepe', + # 'item_ids': [(0, 0, { + # 'applied_on': '3_global', + # 'compute_price': 'formula', + # # 'base': 'pricelist', + # # 'base_pricelist_id': parent.odoo_id.id, + # # 'price_discount': 69, + # # 'pricelist_id': child.odoo_id.id, + # })] + # }) + # print("333333333333333333 --------------------", parent, parent.name, + # parent.item_ids, parent.item_ids.compute_price, + # parent.item_ids.pricelist_id.name) + # + # # child.write({ + # # 'name': 'uu', + # # }) + # parent.write({ + # 'name': 'uu', + # 'sequence': 66, + # 'item_ids': [ + # (1, parent.item_ids.id, { + # 'applied_on': '3_global', + # 'compute_price': 'fixed', + # # 'base': 'pricelist', + # # 'base_pricelist_id': parent.odoo_id.id, + # # 'price_discount': 88, + # # 'pricelist_id': child.odoo_id.id, + # })] + # }) + # print("4444444444444444 --------------", parent, parent.name, parent.item_ids, + # parent.item_ids.compute_price, + # parent.item_ids.pricelist_id.name, parent.sequence) + # + # return + + # with backend.work_on("channel.wubook.pms.room.type") as work: + # adapter = work.component(usage="backend.adapter") + # print(adapter.search_read([('shortname', '=', 'H217')])) + + with backend.work_on("channel.wubook.product.pricelist") as work: + adapter = work.component(usage="backend.adapter") + print("----------------__", adapter) + # res = adapter.create({'name': 'TEST55'}) + res = adapter.search_read([]) + print("**", res) + # res = adapter.search_read([('id', '=', 178429)]) + # res = adapter.search_read([('id', 'in', [178429, 178424,178428,])]) + # res = adapter.search_read([('name', '=', 'TEST55')]) + # res = adapter.search_read([ + # #('id', 'in', [178429, 178424, 178428]), + # #('dfrom', '=', datetime.date(2021, 2, 1)), + # #('dto', '=', datetime.date(2021, 2, 2)), + # #('rooms', 'in', [478457, 478459]), + # # ('rr', '=', 6565) + # ('vpid', '=', 178428) + # ]) + # res = adapter.read(178428) + # print("******", res) + + # res = adapter.write(178538, { + # 'name': 'vT445', + # 'items': [{ + # 'type': 'pricelist', + # 'variation': 11, + # 'variation_type': 2 + # }] + # }) + # print("************", res) + + # test de rooms + res = adapter.write( + 178538, + { + "name": "vT445", + "items": [ + {"type": "pricelist", "variation": 11, "variation_type": 2} + ], + }, + ) + print("************", res) + + res = adapter.search_read([("id", "=", 178538)]) + print("******", res) + + # res = adapter.create({ + # 'name': 'T445', + # 'items': [{ + # 'type': 'pricelist', + # 'variation': 10, + # 'variation_type': -1, + # }] + # }) + # print("******", res) + + # res = adapter.search_read([]) + # print("**", res) + # + # res = adapter.write(178428, { + # 'variation': 66, + # 'variation_type': -1 + # }) + # print("**", res) + # + # + # res = adapter.search_read([]) + # print("**", res) + # res = adapter.delete(177581) + # print(res) + # res = adapter.search_read([('name', '=', 'TEST55'), ('vpid', '!=', False)]) + # print("**", res) + # res = adapter.write(178427, {'name':'test11'}) + # print("**", res) + # res = adapter.write(178429, { + # 'rack': {str(477968): [63, 64, 65, 66, 67, 68, 69]} + # }) + # res = adapter.write(178429, { + # 'rack': {str(477968): [63, 64, 65, 66, 67, 68, 69]} + # }) + # res = adapter.write(178427, {'name':'test11'}) + # print("**", res) + + # res = adapter.search_read([]) + # print("**", res) + + # with backend.work_on("channel.wubook.product.pricelist.item") as work: + # adapter = work.component(usage="backend.adapter") + # + # # res = adapter.search_read([]) + # + # dfrom = datetime.datetime(2021, 1, 1) + # dto = datetime.datetime(2021, 2, 8) + # res = adapter.search_read([ + # ('vpid', '=', 178429), # real + # #('vpid', '=', 178428), # virtual + # ('dfrom', '=', dfrom), + # ('dto', '=', dto), + # ('rooms', 'in', [478457, 478459]), + # #('rr', '=', 6565) + # ]) + # print("*", res) + + return + + r1 = self.env["pms.room.type"].create( + { + "name": "Room type r1", + "list_price": 1.0, + "default_code": "H217", + "pms_property_ids": [(6, 0, [self.ref("pms.main_pms_property")])], + "company_id": False, + } + ) + + with backend.work_on("channel.wubook.pms.room.type") as work: + # adapter = work.component(usage="backend.adapter") + # external_data = adapter.search_read([('shortname', '=', 'H217')]) + # print(external_data) + # + # mapper = work.component(usage="import.mapper") + # map_vals = mapper.map_record(external_data[0]) + # vals = map_vals.values(for_create=True, fields=['shortname', 'name']) + # print(vals) + + mapper = work.component(usage="export.mapper") + t = work.model.create( + { + "odoo_id": r1.id, + "backend_id": backend.id, + } + ) + map_vals = mapper.map_record(t) + vals = map_vals.values(for_create=True) # , fields=['shortname', 'name']) + # print(vals) + + +# class TestWubookConnectorProductPricelistImport(common.TestWubookConnector): +# # non-existing +# @mock.patch.object(xmlrpc.client, "Server") +# def test_import_non_existing_case01(self, mock_xmlrpc_client_server): +# """ +# PRE: - room type r1 does not exist +# ACT: - import r1 from property p1 +# POST: - room type r1 imported +# - r1 has the values from the backend +# """ +# # mock object +# mock_server = common.WubookMockServer() +# mock_xmlrpc_client_server.return_value = mock_server.get_mock() +# +# # ARRANGE +# p1 = self.browse_ref("pms.main_pms_property") +# backend = self.env["channel.wubook.backend"].create( +# { +# "name": "Test backend", +# "pms_property_id": p1.id, +# "model_id": self.ref( +# "connector_pms_wubook.model_channel_wubook_backend" +# ), +# "username": "X", +# "password": "X", +# "property_code": "X", +# "pkey": "X", +# } +# ) +# +# with backend.work_on("channel.wubook.pms.room.type") as work: +# adapter = work.component(usage="backend.adapter") +# +# r1w_values = { +# "name": "Room type r1", +# "shortname": "c1", +# "price": 1.0, +# "availability": 2, +# "board": "ai", +# "occupancy": 6, +# "woodoo": 0, +# } +# r1w_id = adapter.create(r1w_values) +# +# # ACT +# backend.import_room_types() +# +# # ASSERT +# with backend.work_on("channel.wubook.pms.room.type") as work: +# binder = work.component(usage="binder") +# r1 = binder.to_internal(r1w_id, unwrap=True) +# +# mapped_fields = [ +# ("name", "name"), +# ("shortname", "default_code"), +# ("price", "list_price"), +# ] +# odoo_values = [getattr(r1, x) for _, x in mapped_fields] + [r1.pms_property_ids] +# wubook_values = [r1w_values.get(x) for x, _ in mapped_fields] + [ +# backend.pms_property_id +# ] +# +# self.assertListEqual( +# odoo_values, +# wubook_values, +# "The room type data on Odoo does not match the data on Wubook", +# ) diff --git a/connector_pms_wubook/tests/test_product_pricelist.py b/connector_pms_wubook/tests/test_product_pricelist.py new file mode 100644 index 00000000000..6db942f0a86 --- /dev/null +++ b/connector_pms_wubook/tests/test_product_pricelist.py @@ -0,0 +1,200 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from . import common + +_logger = logging.getLogger(__name__) + + +# @tagged("test_debug") +class TestWubookConnectorProductPricelist(common.TestWubookConnector): + def test_product_pricelist_01(self): + # ARRANGE + property1 = self.browse_ref("pms.main_pms_property") + + backend1 = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "user_id": self.user1(property1).id, + "pms_property_id": property1.id, + "backend_type_id": self.backend_type1.parent_id.id, + # **self.fake_credentials, + **self.test_credentials, + } + ) + + # ----------------------------------- + + # --------------------------------------------- + + # with backend.work_on("channel.wubook.product.pricelist") as work: + # binding_model = work.model + # + # binding_model.import_batch(backend, domain=[('name', '=', 'vtest68')]) + # + # binding_model.import_batch(backend, domain=[('name', '=', 'vtest68')]) + # + # pl = self.env["channel.wubook.product.pricelist"].search([]) + # for i in pl: + # print(i) + # + # a = 1 + # + # return + + # --------------------------------------------- + + # parent = self.env["channel.wubook.product.pricelist"].create({ + # 'name': 'child', + # 'backend_id': backend.id, + # }) + # print("222222222222222 --------------", parent, parent.name) + # + # parent.write({ + # 'name': 'pepe', + # 'item_ids': [(0, 0, { + # 'applied_on': '3_global', + # 'compute_price': 'formula', + # # 'base': 'pricelist', + # # 'base_pricelist_id': parent.odoo_id.id, + # # 'price_discount': 69, + # # 'pricelist_id': child.odoo_id.id, + # })] + # }) + # print("333333333333333333 --------------------", parent, parent.name, + # parent.item_ids, parent.item_ids.compute_price, + # parent.item_ids.pricelist_id.name) + # + # # child.write({ + # # 'name': 'uu', + # # }) + # parent.write({ + # 'name': 'uu', + # 'sequence': 66, + # 'item_ids': [ + # (1, parent.item_ids.id, { + # 'applied_on': '3_global', + # 'compute_price': 'fixed', + # # 'base': 'pricelist', + # # 'base_pricelist_id': parent.odoo_id.id, + # # 'price_discount': 88, + # # 'pricelist_id': child.odoo_id.id, + # })] + # }) + # print("4444444444444444 --------------", parent, parent.name, parent.item_ids, + # parent.item_ids.compute_price, + # parent.item_ids.pricelist_id.name, parent.sequence) + # + # return + + # with backend.work_on("channel.wubook.pms.room.type") as work: + # adapter = work.component(usage="backend.adapter") + # print(adapter.search_read([('shortname', '=', 'H217')])) + + # with backend.work_on("channel.wubook.product.pricelist") as work: + # adapter = work.component(usage="backend.adapter") + # print("----------------__", adapter) + # # res = adapter.create({'name': 'TEST55'}) + # res = adapter.search_read([]) + # print("**", res) + # res = adapter.search_read([('id', '=', 178429)]) + # res = adapter.search_read([('id', 'in', [178429, 178424,178428,])]) + # res = adapter.search_read([('name', '=', 'TEST55')]) + # res = adapter.search_read([ + # #('id', 'in', [178429, 178424, 178428]), + # #('dfrom', '=', datetime.date(2021, 2, 1)), + # #('dto', '=', datetime.date(2021, 2, 2)), + # #('rooms', 'in', [478457, 478459]), + # # ('pl', '=', 6565) + # ('vpid', '=', 178428) + # ]) + # res = adapter.read(178428) + # print("******", res) + + # res = adapter.write(178538, { + # 'name': 'vT445', + # 'items': [{ + # 'type': 'pricelist', + # 'variation': 11, + # 'variation_type': 2 + # }] + # }) + # print("************", res) + + # test de rooms + # res = adapter.write( + # 178538, + # { + # "name": "vT445", + # "items": [ + # {"type": "pricelist", "variation": 11, "variation_type": 2} + # ], + # }, + # ) + # print("************", res) + # + # res = adapter.search_read([("id", "=", 178538)]) + # print("******", res) + + # res = adapter.create({ + # 'name': 'T445', + # 'items': [{ + # 'type': 'pricelist', + # 'variation': 10, + # 'variation_type': -1, + # }] + # }) + # print("******", res) + + # res = adapter.search_read([]) + # print("**", res) + # + # res = adapter.write(178428, { + # 'variation': 66, + # 'variation_type': -1 + # }) + # print("**", res) + # + # + # res = adapter.search_read([]) + # print("**", res) + # res = adapter.delete(177581) + # print(res) + # res = adapter.search_read([('name', '=', 'TEST55'), ('vpid', '!=', False)]) + # print("**", res) + # res = adapter.write(178427, {'name':'test11'}) + # print("**", res) + # res = adapter.write(178429, { + # 'rack': {str(477968): [63, 64, 65, 66, 67, 68, 69]} + # }) + # res = adapter.write(178429, { + # 'rack': {str(477968): [63, 64, 65, 66, 67, 68, 69]} + # }) + # res = adapter.write(178427, {'name':'test11'}) + # print("**", res) + + # res = adapter.search_read([]) + # print("**", res) + + # with backend.work_on("channel.wubook.product.pricelist.item") as work: + # adapter = work.component(usage="backend.adapter") + # + # # res = adapter.search_read([]) + # + # dfrom = datetime.datetime(2021, 1, 1) + # dto = datetime.datetime(2021, 2, 8) + # res = adapter.search_read([ + # ('vpid', '=', 178429), # real + # #('vpid', '=', 178428), # virtual + # ('dfrom', '=', dfrom), + # ('dto', '=', dto), + # ('rooms', 'in', [478457, 478459]), + # #('pl', '=', 6565) + # ]) + # print("*", res) + + backend1.export_availability_plans() + backend1.export_room_types() + + return diff --git a/connector_pms_wubook/tests/test_room_type.py b/connector_pms_wubook/tests/test_room_type.py new file mode 100644 index 00000000000..ba802620daf --- /dev/null +++ b/connector_pms_wubook/tests/test_room_type.py @@ -0,0 +1,1226 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging +import xmlrpc.client + +import mock + +from . import common, server + +_logger = logging.getLogger(__name__) + + +class TestWubookConnectorRoomTypeImport(common.TestWubookConnector): + # non-existing + @mock.patch.object(xmlrpc.client, "Server") + def test_import_non_existing_case01(self, mock_xmlrpc_client_server): + """ + PRE: - room type r1 does not exist + ACT: - import r1 from property p1 + POST: - room type r1 imported + - r1 has the values from the backend + """ + # mock object + mock_server = server.MockWubookServer() + mock_xmlrpc_client_server.return_value = mock_server.get_mock() + + # ARRANGE + p1 = self.browse_ref("pms.main_pms_property") + backend = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "pms_property_id": p1.id, + "user_id": self.user1(p1).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + } + ) + + with backend.work_on("channel.wubook.pms.room.type") as work: + adapter = work.component(usage="backend.adapter") + + r1w_values = { + "name": "Room type r1", + "shortname": "c1", + "rtype": 2, + "price": 1.0, + "availability": 2, + "board": "ai", + "occupancy": 6, + "woodoo": 0, + } + r1w_id = adapter.create(r1w_values) + + # ACT + backend.import_room_types() + + # ASSERT + with backend.work_on("channel.wubook.pms.room.type") as work: + binder = work.component(usage="binder") + r1 = binder.to_internal(r1w_id, unwrap=True) + + mapped_fields = [ + ("name", "name"), + ("shortname", "default_code"), + ("price", "list_price"), + ] + odoo_values = ( + [getattr(r1, x) for _, x in mapped_fields] + + [r1.pms_property_ids] + + [len(r1.room_ids)] + ) + wubook_values = ( + [r1w_values.get(x) for x, _ in mapped_fields] + + [backend.pms_property_id] + + [r1w_values.get("availability")] + ) + + self.assertListEqual( + odoo_values, + wubook_values, + "The room type data on Odoo does not match the data on Wubook", + ) + + # existing + @mock.patch.object(xmlrpc.client, "Server") + def test_import_existing_case01(self, mock_xmlrpc_client_server): + """ + PRE: - room type r1 exists + - r1 has code c1 + - r1 has properties p1, p2 + - p1 and p2 have m1 company both + - r1 has company null + - rb1 binding does not exist + - r1 has class cl1 + - cl1 exists + - cl1 has property p1, p2 + - cl1r binding does not exist + ACT: - import r from property p1 + - p1 has company m1 + POST: - new binding rb1 is created + - rb1 contains existing r1 wrapped + - r1 keeps p1, p2 as a properties + - r1 company is still null + - cl1r binding is created + """ + # mock objects + mock_server = server.MockWubookServer() + mock_xmlrpc_client_server.return_value = mock_server.get_mock() + + # ARRANGE + p1 = self.browse_ref("pms.main_pms_property") + m1 = p1.company_id + p2 = self.env["pms.property"].create( + { + "name": "p2", + "company_id": m1.id, + "default_pricelist_id": self.ref("product.list0"), + } + ) + backend = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "pms_property_id": p1.id, + "user_id": self.user1(p1).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + } + ) + cl1 = self.env["pms.room.type.class"].create( + { + "name": "Room type class cl1", + "default_code": "RO", + "pms_property_ids": [(6, 0, [p1.id, p2.id])], + } + ) + + with backend.work_on("channel.wubook.pms.room.type") as work: + adapter = work.component(usage="backend.adapter") + + r1w_values = { + "name": "Room type r1", + "shortname": "c1", + "rtype": 1, + "price": 1.0, + "availability": 2, + "board": "ai", + "occupancy": 6, + "woodoo": 0, + } + r1w_id = adapter.create(r1w_values) + + r1 = self.env["pms.room.type"].create( + { + "name": "Room type r1", + "list_price": 1.0, + "default_code": "c1", + "class_id": cl1.id, + "pms_property_ids": [(6, 0, [p1.id, p2.id])], + "company_id": False, + } + ) + + # ACT + backend.import_room_types() + + # ASSERT + with backend.work_on("channel.wubook.pms.room.type") as work: + binder = work.component(usage="binder") + r1_obtained = binder.to_internal(r1w_id, unwrap=True) + + with self.subTest(): + self.assertEqual( + r1.id, + r1_obtained.id, + "The room type existing differs from the one imported", + ) + with self.subTest(): + self.assertCountEqual( + r1_obtained.pms_property_ids.mapped("id"), + [p1.id, p2.id], + "The properties are not the ones existing in the first place", + ) + with self.subTest(): + self.assertFalse(r1_obtained.company_id, "The company should be null") + + @mock.patch.object(xmlrpc.client, "Server") + def test_import_existing_case02(self, mock_xmlrpc_client_server): + """ + PRE: - room type r1 exists + - r1 has no properties + - r1 has no company + - rb1 binding does not exist + ACT: - import r1 from property p1 + - p1 has company m1 + POST: - new binding rb1 is created + - rb1 contains existing r1 wrapped + - r1 keeps without properties + - r1 company is still null + """ + # mock object + mock_server = server.MockWubookServer() + mock_xmlrpc_client_server.return_value = mock_server.get_mock() + + # ARRANGE + p1 = self.browse_ref("pms.main_pms_property") + backend = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "pms_property_id": p1.id, + "user_id": self.user1(p1).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + } + ) + with backend.work_on("channel.wubook.pms.room.type") as work: + adapter = work.component(usage="backend.adapter") + + r1w_values = { + "name": "Room type r1", + "shortname": "c1", + "rtype": 2, + "price": 1.0, + "availability": 2, + "board": "ai", + "occupancy": 6, + "woodoo": 0, + } + r1w_id = adapter.create(r1w_values) + + r1 = self.env["pms.room.type"].create( + { + "name": "Room type r1", + "list_price": 1.0, + "default_code": "c1", + "class_id": self.ref("pms.pms_room_type_class_0"), + "pms_property_ids": False, + "company_id": False, + } + ) + + # ACT + backend.import_room_types() + + # ASSERT + with backend.work_on("channel.wubook.pms.room.type") as work: + binder = work.component(usage="binder") + r1_obtained = binder.to_internal(r1w_id, unwrap=True) + + asserts = [ + lambda: self.assertEqual( + r1.id, + r1_obtained.id, + "The room type existing differs from the one imported", + ), + lambda: self.assertFalse( + r1_obtained.pms_property_ids, + "The properties should be empty as it was on the first place", + ), + lambda: self.assertFalse( + r1_obtained.company_id, "The company should be null" + ), + ] + for assrt in asserts: + with self.subTest(assrt): + assrt() + + @mock.patch.object(xmlrpc.client, "Server") + def test_import_existing_case03(self, mock_xmlrpc_client_server): + """ + PRE: - room type r1 exists + - r1 has property p2 + - p2 have company m1 + - r1 has company null + - rb1 binding does not exist + ACT: - import r1 from property p1 + - p1 has company m1 + POST: - new binding rb1 is created + - rb1 contains existing r1 wrapped + - r1 has properties p1 and p2 + - r1 company is still null + """ + # mock object + mock_server = server.MockWubookServer() + mock_xmlrpc_client_server.return_value = mock_server.get_mock() + + # ARRANGE + p1 = self.browse_ref("pms.main_pms_property") + m1 = p1.company_id + p2 = self.env["pms.property"].create( + { + "name": "p2", + "company_id": m1.id, + "default_pricelist_id": self.ref("product.list0"), + } + ) + backend = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "pms_property_id": p1.id, + "user_id": self.user1(p1).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + } + ) + + cl1 = self.env["pms.room.type.class"].create( + { + "name": "Room type class cl1", + "default_code": "RO", + "pms_property_ids": [(6, 0, [p2.id])], + } + ) + + with backend.work_on("channel.wubook.pms.room.type") as work: + adapter = work.component(usage="backend.adapter") + + r1w_values = { + "name": "Room type r1", + "shortname": "c1", + "rtype": 1, + "price": 1.0, + "availability": 2, + "board": "ai", + "occupancy": 6, + "woodoo": 0, + } + r1w_id = adapter.create(r1w_values) + + r1 = self.env["pms.room.type"].create( + { + "name": "Room type r1", + "list_price": 1.0, + "default_code": "c1", + "class_id": cl1.id, + "pms_property_ids": [(6, 0, [p2.id])], + "company_id": False, + } + ) + + # ACT + backend.import_room_types() + + # ASSERT + with backend.work_on("channel.wubook.pms.room.type") as work: + binder = work.component(usage="binder") + r1_obtained = binder.to_internal(r1w_id, unwrap=True) + + asserts = [ + lambda: self.assertEqual( + r1.id, + r1_obtained.id, + "The room type existing differs from the one imported", + ), + lambda: self.assertCountEqual( + r1_obtained.pms_property_ids.mapped("id"), + [p1.id, p2.id], + "The property of the backend should have been added to room type", + ), + lambda: self.assertFalse( + r1_obtained.company_id, "The company should be null" + ), + ] + for assrt in asserts: + with self.subTest(assrt): + assrt() + + @mock.patch.object(xmlrpc.client, "Server") + def test_import_existing_case04(self, mock_xmlrpc_client_server): + """ + PRE: - room type r1 exists + - r1 has property p1, p2 + - p1, p2 have company m1 + - r1 has company null + - rb1 binding does not exist + ACT: - import r1 from property p4 + - p4 has company m1 + POST: - new binding rb1 is created + - rb1 contains existing r1 wrapped + - r1 has properties p1, p2, p4 + - r1 company is still null + """ + # mock object + mock_server = server.MockWubookServer() + mock_xmlrpc_client_server.return_value = mock_server.get_mock() + + # ARRANGE + p1 = self.browse_ref("pms.main_pms_property") + m1 = p1.company_id + p2 = self.env["pms.property"].create( + { + "name": "p2", + "company_id": m1.id, + "default_pricelist_id": self.ref("product.list0"), + } + ) + p4 = self.env["pms.property"].create( + { + "name": "p4", + "company_id": m1.id, + "default_pricelist_id": self.ref("product.list0"), + } + ) + + backend = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "pms_property_id": p4.id, + "user_id": self.user1(p4).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + } + ) + + with backend.work_on("channel.wubook.pms.room.type") as work: + adapter = work.component(usage="backend.adapter") + + r1w_values = { + "name": "Room type r1", + "shortname": "c1", + "rtype": 1, + "price": 1.0, + "availability": 2, + "board": "ai", + "occupancy": 6, + "woodoo": 0, + } + r1w_id = adapter.create(r1w_values) + + r1 = self.env["pms.room.type"].create( + { + "name": "Room type r1", + "list_price": 1.0, + "default_code": "c1", + "class_id": self.ref("pms.pms_room_type_class_0"), + "pms_property_ids": [(6, 0, [p1.id, p2.id])], + "company_id": False, + } + ) + + # ACT + backend.import_room_types() + + # ASSERT + with backend.work_on("channel.wubook.pms.room.type") as work: + binder = work.component(usage="binder") + r1_obtained = binder.to_internal(r1w_id, unwrap=True) + + asserts = [ + lambda: self.assertEqual( + r1.id, + r1_obtained.id, + "The room type existing differs from the one imported", + ), + lambda: self.assertCountEqual( + r1_obtained.pms_property_ids.mapped("id"), + [p1.id, p2.id, p4.id], + "The property of the backend should have been added to room type", + ), + lambda: self.assertFalse( + r1_obtained.company_id, "The company should be null" + ), + ] + for assrt in asserts: + with self.subTest(assrt): + assrt() + + @mock.patch.object(xmlrpc.client, "Server") + def test_import_existing_case05(self, mock_xmlrpc_client_server): + """ + PRE: - room type r1 exists + - r1 has property p1, p2 + - p1, p2 have company m1 + - r1 has company null + - r1 has class cl1 + - cl1 exists + - cl1 has property p1, p2 + - cl1r binding does not exist + - r1 has 2 bindings rb1 and rb2 + - rb1 is from p1 + - rb2 id from p2 + ACT: - remove p2 + - import r1 from property p2 + - p2 has company m1 + POST: - rb1 is bound to r1 + - r1 has properties p1, p2 (p2 is re-added) + - r1 company is still null + """ + # mock object + mock_server = server.MockWubookServer() + mock_xmlrpc_client_server.return_value = mock_server.get_mock() + + # ARRANGE + p1 = self.browse_ref("pms.main_pms_property") + m1 = p1.company_id + p2 = self.env["pms.property"].create( + { + "name": "p2", + "company_id": m1.id, + "default_pricelist_id": self.ref("product.list0"), + } + ) + backend1 = self.env["channel.wubook.backend"].create( + { + "name": "Test backend 1", + "pms_property_id": p1.id, + "user_id": self.user1(p1).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + } + ) + backend2 = self.env["channel.wubook.backend"].create( + { + "name": "Test backend 2", + "pms_property_id": p2.id, + "user_id": self.user1(p2).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + "property_code": "X2", + } + ) + + cl1 = self.env["pms.room.type.class"].create( + { + "name": "Room", + "default_code": "RO", + "pms_property_ids": [(6, 0, [p1.id, p2.id])], + } + ) + + with backend1.work_on("channel.wubook.pms.room.type") as work: + adapter1 = work.component(usage="backend.adapter") + r1w_values = { + "name": "Room type r1", + "shortname": "c1", + "rtype": 1, + "price": 1.0, + "availability": 2, + "board": "ai", + "occupancy": 6, + "woodoo": 0, + } + r1w_id = adapter1.create(r1w_values) + + with backend2.work_on("channel.wubook.pms.room.type") as work: + adapter2 = work.component(usage="backend.adapter") + r2w_values = dict(r1w_values) + r2w_id = adapter2.create(r2w_values) + + # create the bindings + r1b = self.env["channel.wubook.pms.room.type"].create( + { + "name": "Room type r1", + "list_price": 1.0, + "default_code": "c1", + "class_id": cl1.id, + "pms_property_ids": [(6, 0, [p1.id, p2.id])], + "company_id": False, + "backend_id": backend1.id, + } + ) + with backend1.work_on("channel.wubook.pms.room.type") as work: + binder1 = work.component(usage="binder") + binder1.bind(r1w_id, r1b) + r1 = binder1.to_internal(r1w_id, unwrap=True) + + r2b = self.env["channel.wubook.pms.room.type"].create( + { + "odoo_id": r1.id, + "backend_id": backend2.id, + } + ) + with backend2.work_on("channel.wubook.pms.room.type") as work: + binder2 = work.component(usage="binder") + binder2.bind(r2w_id, r2b) + + # ACT + r1.pms_property_ids = [(3, p2.id, 0)] + backend2.import_room_types() + + # ASSERT + self.assertCountEqual( + r1.pms_property_ids.ids, + [p1.id, p2.id], + "The binding does not contain the two original properties", + ) + + @mock.patch.object(xmlrpc.client, "Server") + def test_import_existing_case06(self, mock_xmlrpc_client_server): + """ + PRE: - room type r1 exists + - r1 has property p1, p2 + - p1, p2 have company m1 + - r1 has company null + - rb1 and rb2 bindings exist + ACT: - remove p1 and p2 + - import r1 from property p1 + - p1 has company m1 + - import r1 from property p2 + - p2 has company m1 + POST: - rb1 is bound to r1 + - r1 has no properties + - r1 company is still null + """ + # mock object + mock_server = server.MockWubookServer() + mock_xmlrpc_client_server.return_value = mock_server.get_mock() + + # ARRANGE + p1 = self.browse_ref("pms.main_pms_property") + m1 = p1.company_id + p2 = self.env["pms.property"].create( + { + "name": "p2", + "company_id": m1.id, + "default_pricelist_id": self.ref("product.list0"), + } + ) + backend1 = self.env["channel.wubook.backend"].create( + { + "name": "Test backend 1", + "pms_property_id": p1.id, + "user_id": self.user1(p1).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + } + ) + backend2 = self.env["channel.wubook.backend"].create( + { + "name": "Test backend 2", + "pms_property_id": p2.id, + "user_id": self.user1(p2).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + "property_code": "X2", + } + ) + with backend1.work_on("channel.wubook.pms.room.type") as work: + adapter1 = work.component(usage="backend.adapter") + r1w_values = { + "name": "Room type r1", + "shortname": "c1", + "rtype": 2, + "price": 1.0, + "availability": 2, + "board": "ai", + "occupancy": 6, + "woodoo": 0, + } + r1w_id = adapter1.create(r1w_values) + + with backend2.work_on("channel.wubook.pms.room.type") as work: + adapter2 = work.component(usage="backend.adapter") + r2w_values = { + "name": "Room type r1", + "shortname": "c1", + "rtype": 2, + "price": 1.0, + "availability": 2, + "board": "ai", + "occupancy": 6, + "woodoo": 0, + } + r2w_id = adapter2.create(r2w_values) + + # create the bindings + r1b = self.env["channel.wubook.pms.room.type"].create( + { + "name": "Room type r1", + "list_price": 1.0, + "default_code": "c1", + "class_id": self.ref("pms.pms_room_type_class_0"), + "pms_property_ids": [(6, 0, [p1.id, p2.id])], + "company_id": False, + "backend_id": backend1.id, + } + ) + with backend1.work_on("channel.wubook.pms.room.type") as work: + binder1 = work.component(usage="binder") + binder1.bind(r1w_id, r1b) + r1 = binder1.to_internal(r1w_id, unwrap=True) + + r2b = self.env["channel.wubook.pms.room.type"].create( + { + "odoo_id": r1.id, + "backend_id": backend2.id, + } + ) + with backend2.work_on("channel.wubook.pms.room.type") as work: + binder2 = work.component(usage="binder") + binder2.bind(r2w_id, r2b) + + # ACT + r1.pms_property_ids = [(3, p1.id, 0), (3, p2.id, 0)] + backend1.import_room_types() + backend2.import_room_types() + + # ASSERT + self.assertFalse( + r1.pms_property_ids, + "The binding still contains properties", + ) + + +class TestWubookConnectorRoomTypeExport(common.TestWubookConnector): + @mock.patch.object(xmlrpc.client, "Server") + def test_export_existing_case01(self, mock_xmlrpc_client_server): + """ + PRE: - r1 exists + - r1 has code 'c1' + - r1 has property p1 + - p1 has company m1 + - r1 has no company defined + - r1 has no binding + - on the backend exists a record with shortname 'c1' + - r1w has different name and price than r1 + ACT: - export all rooms (only r1 exists) + POST: - r1 has binding r1b + - r1b has the same external_id as the id the record + on the backend + - r1b additional fields min_price, max_price are created + with the values of the backend + - r1 fields as list_price is not imported from the backend and + it kepts the original value + - r1w has the same name and price than r1 + """ + # mock object + mock_server = server.MockWubookServer() + mock_xmlrpc_client_server.return_value = mock_server.get_mock() + + # ARRANGE + p1 = self.browse_ref("pms.main_pms_property") + + backend = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "pms_property_id": p1.id, + "user_id": self.user1(p1).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + } + ) + with backend.work_on("channel.wubook.pms.room.type") as work: + binding_model = work.model + adapter = work.component(usage="backend.adapter") + binder = work.component(usage="binder") + + bs1 = self.env["pms.board.service"].create( + { + "name": "All included", + "default_code": "AI", + "board_service_line_ids": [ + ( + 0, + 0, + { + "product_id": self.env.ref( + "pms.pms_service_0_product_template" + ).id, + }, + ) + ], + } + ) + r1_values = { + "name": "Room type r1", + "list_price": 30.0, + "default_code": "c1", + "class_id": self.env.ref("pms.pms_room_type_class_0").id, + "pms_property_ids": [(6, 0, [p1.id])], + "board_service_room_type_ids": [ + ( + 0, + 0, + { + "pms_board_service_id": bs1.id, + }, + ) + ], + "company_id": False, + } + r1 = self.env["pms.room.type"].create(r1_values) + + r1w_values = { + "name": "Room type Diff", + "shortname": "c1", + "rtype": 2, + "min_price": 5.0, + "max_price": 200.0, + "price": 66, + "availability": 2, + "board": "ai", + "occupancy": 6, + "woodoo": 0, + } + r1w_id = adapter.create(r1w_values) + + # ACT + binding_model.export_batch(backend_record=backend, domain=[("id", "=", r1.id)]) + + # ASSERT + r1b = binder.wrap_record(r1) + + with self.subTest(): + self.assertTrue( + bool(binder.unwrap_binding(r1b)), "The binding should exist" + ) + with self.subTest(): + self.assertEqual( + binder.unwrap_binding(r1b).id, + r1.id, + "The binding exists but the id of the real record should match", + ) + with self.subTest(): + self.assertEqual( + r1b.external_id, + r1w_id, + "The external id's should be the same on the binding " + "and on the backend ", + ) + with self.subTest(): + self.assertEqual( + [r1b.min_price, r1b.max_price], + [r1w_values["min_price"], r1w_values["max_price"]], + "The additional fields have not been imported to binding", + ) + with self.subTest(): + self.assertEqual( + r1.list_price, + r1_values["list_price"], + "The price belongs to the real record and not the binding, " + "so it shouldn't be changed", + ) + with self.subTest(): + r1w_new = adapter.search_read([("id", "=", r1w_id)])[0] + self.assertEqual( + [r1.name, r1.list_price], + [r1w_new["name"], r1w_new["price"]], + "The price and the name were not exported", + ) + + @mock.patch.object(xmlrpc.client, "Server") + def test_export_existing_case02(self, mock_xmlrpc_client_server): + """ + PRE: - r1 exists + - r1 has code 'c1' + - r1 has property p1 + - p1 has company m1 + - r1 has no company defined + - r1 has binding r1b + - r1b has no external_id + - r1w exists on the backend + - r1w has different name and price than r1 + ACT: - export all rooms (only r1 exists) + POST: - r1 still has a binding + - r1 has r1b binding (the same as before) + - the odoo record linked to r1b is still the same as before + - r1b has external_id and is the id of the external record + - r1b additional fields min_price, max_price are not imported + and they kept their original value because the binding + exists eventhough it has not external_id + - r1 fields as list_price is not imported from the backend and + it kepts the original value + - r1w has the same name and price than r1 + """ + # mock object + mock_server = server.MockWubookServer() + mock_xmlrpc_client_server.return_value = mock_server.get_mock() + + # ARRANGE + p1 = self.browse_ref("pms.main_pms_property") + backend = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "pms_property_id": p1.id, + "user_id": self.user1(p1).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + } + ) + with backend.work_on("channel.wubook.pms.room.type") as work: + binding_model = work.model + adapter = work.component(usage="backend.adapter") + binder = work.component(usage="binder") + + r1_values = { + "name": "Room type r1", + "list_price": 30.0, + "default_code": "c1", + "class_id": self.ref("pms.pms_room_type_class_0"), + "pms_property_ids": [(6, 0, [p1.id])], + "company_id": False, + } + r1 = self.env["pms.room.type"].create(r1_values) + r1b = binder.wrap_record(r1, force=True) + r1b_values = { + "min_price": 11.0, + "max_price": 21.0, + } + r1b.write(r1b_values) + + r1w_values = { + "name": "Room type Diff", + "shortname": "c1", + "rtype": 2, + "min_price": 5.0, + "max_price": 200.0, + "price": 66.0, + "availability": 2, + "board": "ai", + "occupancy": 6, + "woodoo": 0, + } + r1w_id = adapter.create(r1w_values) + + # ACT + binding_model.export_batch(backend_record=backend, domain=[("id", "=", r1.id)]) + + # ASSERT + r1b_new = binder.wrap_record(r1) + + with self.subTest(): + self.assertTrue( + bool(binder.unwrap_binding(r1b_new)), "The binding should exist" + ) + with self.subTest(): + self.assertEqual( + r1b.id, + r1b_new.id, + "The binding should be the same as the one before export", + ) + with self.subTest(): + self.assertEqual( + r1.id, + binder.unwrap_binding(r1b_new).id, + "The id of the real record on the binding does not match", + ) + with self.subTest(): + self.assertEqual( + r1b_new.external_id, + r1w_id, + "The external id's should be the same on the binding " + "and on the backend ", + ) + with self.subTest(): + self.assertEqual( + [r1b_new.min_price, r1b_new.max_price], + [r1b_values["min_price"], r1b_values["max_price"]], + "The additional fields have been imported to binding " + "and it shouldn't have happened", + ) + with self.subTest(): + r1w_new = adapter.search_read([("id", "=", r1w_id)])[0] + self.assertEqual( + [r1.name, r1.list_price], + [r1w_new["name"], r1w_new["price"]], + "The price and the name were not exported", + ) + + +# @tagged("test_debug") +class TestWubookConnectorRoomTypeReuseBinding(common.TestWubookConnector): + # existing + @mock.patch.object(xmlrpc.client, "Server") + def test_export_reuse_binding_case01(self, mock_xmlrpc_client_server): + """ + PRE: - r1 exists + - r1 has code 'c1' + - r1 has no binding + - on the backend exists a record with shortname 'c1' + ACT: - export r1 + POST: - r1 has binding r1b + - r1b has the same external_id as the id the record + on the backend + - r1b additional fields min_price, max_price are created + with the values of the backend + - r1 fields as list_price is not imported from the backend and + it kepts the original value + :return: + """ + # mock object + mock_server = server.MockWubookServer() + mock_xmlrpc_client_server.return_value = mock_server.get_mock() + + # ARRANGE + p1 = self.browse_ref("pms.main_pms_property") + backend = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "pms_property_id": p1.id, + "user_id": self.user1(p1).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + } + ) + + with backend.work_on("channel.wubook.pms.room.type") as work: + adapter = work.component(usage="backend.adapter") + r1w_values = { + "name": "Room type r1", + "shortname": "c1", + "rtype": 2, + "price": 100.0, + "availability": 2, + "board": "ai", + "occupancy": 6, + "woodoo": 0, + "min_price": 5, + "max_price": 200, + } + # r1w_id + adapter.create(r1w_values) + + r1_values = { + "name": "Room type r1", + "list_price": 30, + "default_code": "c1", + "class_id": self.ref("pms.pms_room_type_class_0"), + "pms_property_ids": [(6, 0, [self.ref("pms.main_pms_property")])], + "company_id": False, + } + r1 = self.env["pms.room.type"].create(r1_values) + + with backend.work_on("channel.wubook.pms.room.type") as work: + binder = work.component(usage="binder") + + # ACT + r1b = binder.to_binding_from_internal_key(r1) + + # ASSERT + asserts = [ + ( + binder.unwrap_binding(r1b), + r1, + "The binding does not belong to real record", + ), + ( + binder.to_external(r1, wrap=True), + r1b.external_id, + "The external id is not the same on both binding and real", + ), + ( + [getattr(r1b, x) for x in ["max_price", "min_price"]], + [r1w_values[x] for x in ["max_price", "min_price"]], + "The additional fields have not been imported to binding", + ), + ( + r1b.list_price, + r1_values["list_price"], + "The price belongs to the real record and not the binding, " + "so it shouldn't be changed", + ), + ] + for assrt in asserts: + with self.subTest(): + self.assertEqual(*assrt) + + @mock.patch.object(xmlrpc.client, "Server") + def test_export_reuse_binding_case02(self, mock_xmlrpc_client_server): + """ + PRE: - r1 exists + - r1 has code 'c1' + - r1 has binding r1b + - r1b has no external_id + - on the backend exists a record with shortname 'c1' + ACT: - export r1 + POST: - r1 still has binding r1b and is the same + - r1b has external_id and is the id of the external record + - r1b additional fields min_price, max_price are created + with the values of the backend + - r1 fields as list_price is not imported from the backend and + it kepts the original value + :return: + """ + # mock object + mock_server = server.MockWubookServer() + mock_xmlrpc_client_server.return_value = mock_server.get_mock() + + # ARRANGE + p1 = self.browse_ref("pms.main_pms_property") + backend = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "pms_property_id": p1.id, + "user_id": self.user1(p1).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + } + ) + + with backend.work_on("channel.wubook.pms.room.type") as work: + adapter = work.component(usage="backend.adapter") + r1w_values = { + "name": "Room type r1", + "shortname": "c1", + "rtype": 2, + "price": 1.0, + "availability": 2, + "board": "ai", + "occupancy": 6, + "woodoo": 0, + "min_price": 5.0, + "max_price": 200.0, + } + r1w_id = adapter.create(r1w_values) + + r1_values = { + "name": "Room type r1", + "list_price": 30, + "default_code": "c1", + "class_id": self.ref("pms.pms_room_type_class_0"), + "pms_property_ids": [(6, 0, [self.ref("pms.main_pms_property")])], + "company_id": False, + } + r1 = self.env["pms.room.type"].create(r1_values) + + r1b0 = self.env["channel.wubook.pms.room.type"].create( + { + "odoo_id": r1.id, + "backend_id": backend.id, + } + ) + + with backend.work_on("channel.wubook.pms.room.type") as work: + binder = work.component(usage="binder") + + # ACT + r1b = binder.to_binding_from_internal_key(r1) + + # ASSERT + asserts = [ + ( + r1b.id, + r1b0.id, + "The binding has changed it should be the same only updated", + ), + (r1b.external_id, r1w_id, "The external id is not the one on the backend"), + ( + [getattr(r1b, x) for x in ["max_price", "min_price"]], + [r1w_values[x] for x in ["max_price", "min_price"]], + "The additional fields have not been imported to binding", + ), + ( + r1b.list_price, + r1_values["list_price"], + "The price belongs to the real record and not the binding, " + "so it shouldn't be changed", + ), + ] + for assrt in asserts: + with self.subTest(): + self.assertEqual(*assrt) + + +# TODO: add this mock test and add more cases +# @tagged("test_debug") +# class TestServerMock(common.TestWubookConnector): +# @mock.patch.object(xmlrpc.client, "Server") +# def test_create_room_case01(self, mock_xmlrpc_client_server): +# # mock object +# mock_server = server.MockWubookServer() +# mock_xmlrpc_client_server.return_value = mock_server.get_mock() +# +# # ARRANGE +# p1 = self.browse_ref("pms.main_pms_property") +# +# backend = self.env["channel.wubook.backend"].create( +# { +# "name": "Test backend 1", +# "pms_property_id": p1.id, +# "model_id": self.ref( +# "connector_pms_wubook.model_channel_wubook_backend" +# ), +# "username": "X", +# "password": "X", +# "property_code": "X", +# "pkey": "X", +# } +# ) +# +# with backend.work_on("channel.wubook.pms.room.type") as work: +# adapter = work.component(usage="backend.adapter") +# +# r1w ={ +# "woodoo": 0, +# "name": "Room type r1", +# "occupancy": 2, +# "price": 100, +# "availability": 1, +# "shortname": "c1", +# "board": 'nb', +# "min_price": 5, +# "max_price": 200, +# } +# +# r = adapter.create(r1w) +# print(r) +# return +# u = adapter.search_read([]) +# print(u) +# +# u = adapter.search([]) +# print(u) +# +# return +# r1w['shortname'] = 'c2' +# r = adapter.create(r1w) +# print(r) +# +# r = adapter.search_read([]) +# print(r) +# +# r = adapter.search([]) +# print(r) +# +# r1w_values = dict(r1w) +# r1w_values['name']="CHANGED!!!" +# r = adapter.write(1, r1w_values) +# +# r = adapter.search([]) +# +# r = adapter.search_read([('id', '=', 1)]) +# print("sear", r) + +# @tagged('test_debug') +# class TestRT(common.TestWubookConnector): +# pass diff --git a/connector_pms_wubook/tests/test_room_type_class.py b/connector_pms_wubook/tests/test_room_type_class.py new file mode 100644 index 00000000000..dbbc6e11295 --- /dev/null +++ b/connector_pms_wubook/tests/test_room_type_class.py @@ -0,0 +1,67 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging +import xmlrpc.client + +import mock + +from . import common, server + +_logger = logging.getLogger(__name__) + + +# @tagged('test_debug') +class TestWubookConnectorRoomTypeClassImport(common.TestWubookConnector): + # non-existing + @mock.patch.object(xmlrpc.client, "Server") + def test_import_non_existing_case01(self, mock_xmlrpc_client_server): + """ + PRE: - room type class cl1 does not exist + ACT: - import cl1 from property p1 + POST: - room type class cl1 imported + - cl1 has the values from the backend + """ + # mock object + mock_server = server.MockWubookServer() + mock_xmlrpc_client_server.return_value = mock_server.get_mock() + + # ARRANGE + p1 = self.browse_ref("pms.main_pms_property") + backend = self.env["channel.wubook.backend"].create( + { + "name": "Test backend", + "pms_property_id": p1.id, + "user_id": self.user1(p1).id, + "backend_type_id": self.backend_type1.parent_id.id, + **self.fake_credentials, + } + ) + + cl1w_values = { + "name": "Apartment", + } + cl1w_id = 2 + + # ACT + backend.import_room_type_classes() + + # ASSERT + with backend.work_on("channel.wubook.pms.room.type.class") as work: + binder = work.component(usage="binder") + cl1 = binder.to_internal(cl1w_id, unwrap=True) + + mapped_fields = [ + ("name", "name"), + ] + odoo_values = [getattr(cl1, x) for _, x in mapped_fields] + [ + cl1.pms_property_ids + ] + wubook_values = [cl1w_values.get(x) for x, _ in mapped_fields] + [ + backend.pms_property_id + ] + + self.assertListEqual( + odoo_values, + wubook_values, + "The room type class data on Odoo does not match the data on Wubook", + ) diff --git a/connector_pms_wubook/views/channel_wubook_backend_type_views.xml b/connector_pms_wubook/views/channel_wubook_backend_type_views.xml new file mode 100644 index 00000000000..4ed87fbb20e --- /dev/null +++ b/connector_pms_wubook/views/channel_wubook_backend_type_views.xml @@ -0,0 +1,44 @@ + + + + + channel.wubook.backend.type.form + channel.wubook.backend.type + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/connector_pms_wubook/views/channel_wubook_backend_views.xml b/connector_pms_wubook/views/channel_wubook_backend_views.xml new file mode 100644 index 00000000000..67b705688e9 --- /dev/null +++ b/connector_pms_wubook/views/channel_wubook_backend_views.xml @@ -0,0 +1,209 @@ + + + + + channel.wubook.backend.form + channel.wubook.backend + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/connector_pms_wubook/views/pms_availability_plan_views.xml b/connector_pms_wubook/views/pms_availability_plan_views.xml new file mode 100644 index 00000000000..36d568f81b8 --- /dev/null +++ b/connector_pms_wubook/views/pms_availability_plan_views.xml @@ -0,0 +1,56 @@ + + + + + channel.wubook.pms.availability.plan.rule.form + pms.availability.plan.rule + + + + + + + + + + pms.availability.plan.wubook.connector.form + pms.availability.plan + + + + 0 + + + + + + + + + +