diff --git a/setup/website_sale_resource_booking/odoo/addons/website_sale_resource_booking b/setup/website_sale_resource_booking/odoo/addons/website_sale_resource_booking new file mode 120000 index 0000000000..4c3f0633c0 --- /dev/null +++ b/setup/website_sale_resource_booking/odoo/addons/website_sale_resource_booking @@ -0,0 +1 @@ +../../../../website_sale_resource_booking \ No newline at end of file diff --git a/setup/website_sale_resource_booking/setup.py b/setup/website_sale_resource_booking/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/website_sale_resource_booking/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/website_sale_resource_booking/README.rst b/website_sale_resource_booking/README.rst new file mode 100644 index 0000000000..686ed441c2 --- /dev/null +++ b/website_sale_resource_booking/README.rst @@ -0,0 +1,120 @@ +================================================ +Sell resource booking products in your eCommerce +================================================ + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! 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%2Fe--commerce-lightgray.png?logo=github + :target: https://github.com/OCA/e-commerce/tree/15.0/website_sale_resource_booking + :alt: OCA/e-commerce +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/e-commerce-15-0/e-commerce-15-0-website_sale_resource_booking + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/webui/builds.html?repo=OCA/e-commerce&target_branch=15.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the functionality of ``sale_resource_booking`` to support +the eCommerce use case and to allow your visitors to buy products that produce +a resource booking, and pre-book them before buying. + +You can also set a timeout for those pre-bookings to expire if unpaid. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +To install this module, you need these dependencies: + +* ``resource_booking`` from https://github.com/OCA/calendar +* ``sale_resource_booking`` from https://github.com/OCA/sale-workflow + +Usage +===== + +To use this module, you need to know how to use ``sale_resource_booking`` and +``resource_booking``. This document doesn't explain the details for those +related modules. + +All products that you link to a resource booking type will allow pre-bookings +if sold from your eCommerce. To configure those pre-bookings timeout: + +#. Go to the product form in the backend. +#. Use the *Resource booking timeout* field, in the *Sales* tab. + +When you go to that product's eCommerce page, you'll see a little message above +the *Add to cart* button, telling the user that they will be able to pre-book it +before buying. + +When you add to your cart one (or more) bookable products, you will see in the +eCommerce checkout wizard a new step that you will have to follow to be able to +buy. This step will display a calendar with bookable slots for you to choose. + +When you are redirected to payment, make sure to pay before your pre-bookings +expire! + +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 +~~~~~~~ + +* Tecnativa + +Contributors +~~~~~~~~~~~~ + +* `Tecnativa `_: + + * Jairo Llopis + * Stefan Ungureanu + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-Yajo| image:: https://github.com/Yajo.png?size=40px + :target: https://github.com/Yajo + :alt: Yajo + +Current `maintainer `__: + +|maintainer-Yajo| + +This module is part of the `OCA/e-commerce `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_sale_resource_booking/__init__.py b/website_sale_resource_booking/__init__.py new file mode 100644 index 0000000000..f7209b1710 --- /dev/null +++ b/website_sale_resource_booking/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/website_sale_resource_booking/__manifest__.py b/website_sale_resource_booking/__manifest__.py new file mode 100644 index 0000000000..8fa9f4abe1 --- /dev/null +++ b/website_sale_resource_booking/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Sell resource booking products in your eCommerce", + "summary": "Let customers book resources temporarily before buying", + "version": "15.0.1.0.0", + "development_status": "Beta", + "category": "Website", + "website": "https://github.com/OCA/e-commerce", + "author": "Tecnativa, Odoo Community Association (OCA)", + "maintainers": ["Yajo"], + "license": "AGPL-3", + "depends": ["sale_resource_booking", "website_sale"], + "data": [ + "data/ir_cron_data.xml", + "templates/website_sale.xml", + "views/product_template_view.xml", + ], + "assets": { + "web.assets_frontend": [ + "/website_sale_resource_booking/static/src/css/website_sale_resource_booking.scss" + ], + "web.assets_tests": [ + "/website_sale_resource_booking/static/src/js/tour_checkout.js", + ], + }, +} diff --git a/website_sale_resource_booking/controllers/__init__.py b/website_sale_resource_booking/controllers/__init__.py new file mode 100644 index 0000000000..12a7e529b6 --- /dev/null +++ b/website_sale_resource_booking/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/website_sale_resource_booking/controllers/main.py b/website_sale_resource_booking/controllers/main.py new file mode 100644 index 0000000000..136cbcdce7 --- /dev/null +++ b/website_sale_resource_booking/controllers/main.py @@ -0,0 +1,117 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from datetime import datetime +from urllib.parse import quote_plus + +from dateutil.parser import isoparse + +from odoo import _ +from odoo.exceptions import ValidationError +from odoo.http import request, route +from odoo.tests.common import Form + +from ...website_sale.controllers import main + + +class WebsiteSale(main.WebsiteSale): + def _get_bookings(self): + """Obtain bookings from current cart.""" + order = request.website.sale_get_order() + return order.mapped("order_line.resource_booking_ids") + + def _get_indexed_booking(self, index): + """Get indexed booking from current cart. + + :param int index: 1 is the 1st element. + """ + bookings = self._get_bookings().sorted("id") + if index > len(bookings): + raise IndexError() + return bookings[index - 1] + + def checkout_redirection(self, order): + """Redirect to scheduling bookings if still not done.""" + order.order_line._sync_resource_bookings() + bookings = order.mapped("order_line.resource_booking_ids") + for booking in bookings: + if booking.state == "pending": + return request.redirect("/shop/booking/1/schedule") + return super().checkout_redirection(order) + + @route( + [ + "/shop/booking//schedule", + "/shop/booking//schedule//", + ], + type="http", + auth="public", + website=True, + sitemap=False, + ) + def booking_schedule(self, index, year=None, month=None, error=None, **post): + """Schedule pending bookings.""" + # Proceed to checkout if there are no bookings in this cart + bookings = self._get_bookings().with_context(checkout_booking_index=index) + if not bookings: + return request.redirect("/shop/checkout") + # Proceed to checkout if we passed the last booking + try: + booking = self._get_indexed_booking(index).with_context( + checkout_booking_index=index + ) + except IndexError: + return request.redirect("/shop/checkout") + count = len(bookings) + values = booking.with_context( + tz=booking.type_id.resource_calendar_id.tz + )._get_calendar_context(year, month) + values.update( + { + "booking_index": index, + "bookings_count": count, + "error": error, + "website_sale_order": request.website.sale_get_order(), + "wizard_title": _("Pre-schedule your booking (%(index)d of %(total)d)") + % {"index": index, "total": count}, + } + ) + return request.render("website_sale_resource_booking.scheduling", values) + + @route( + ["/shop/booking//confirm"], + type="http", + auth="public", + website=True, + sitemap=False, + ) + def booking_confirm(self, index, partner_name, partner_email, when, **post): + """Pre-reserve resource booking.""" + booking_sudo = ( + self._get_indexed_booking(index) + .sudo() + .with_context( + # Avoid calendar notifications now, SO is still draft + dont_notify=True, + no_mail_to_attendees=True, + ) + ) + when_tz_aware = isoparse(when) + when_naive = datetime.utcfromtimestamp(when_tz_aware.timestamp()) + try: + with Form(booking_sudo) as booking_form: + booking_form.start = when_naive + except ValidationError as error: + url = "/shop/booking/{}/schedule?error={}".format( + index, quote_plus(error.name) + ) + return request.redirect(url) + # Store partner info to autocreate and autoconfirm later + product = booking_sudo.sale_order_line_id.product_id + booking_sudo.write( + { + "expiration": product.resource_booking_expiration, + "prereserved_email": partner_email, + "prereserved_name": partner_name, + } + ) + return request.redirect("/shop/booking/{}/schedule".format(index + 1)) diff --git a/website_sale_resource_booking/data/ir_cron_data.xml b/website_sale_resource_booking/data/ir_cron_data.xml new file mode 100644 index 0000000000..e559ef4d0f --- /dev/null +++ b/website_sale_resource_booking/data/ir_cron_data.xml @@ -0,0 +1,16 @@ + + + + + Auto-cancel expired resource bookings + + code + model._cron_cancel_expired() + + 1 + minutes + -1 + + + diff --git a/website_sale_resource_booking/i18n/es.po b/website_sale_resource_booking/i18n/es.po new file mode 100644 index 0000000000..ec6cc78745 --- /dev/null +++ b/website_sale_resource_booking/i18n/es.po @@ -0,0 +1,147 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_resource_booking +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-08-09 08:39+0000\n" +"PO-Revision-Date: 2023-07-06 15:56+0200\n" +"Last-Translator: Jairo Llopis \n" +"Language-Team: \n" +"Language: es_ES\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.0.1\n" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.product +msgid "" +"\n" +" From the cart, you will be able to make a pre-reservation, " +"which will expire" +msgstr "" +"\n" +" Desde el carrito, podrá realizar una prereserva, la cual " +"expirará" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling +msgid "" +"\n" +" Return to Cart" +msgstr "" +"\n" +" Volver al carrito" + +#. module: website_sale_resource_booking +#: model:ir.actions.server,name:website_sale_resource_booking.cron_expire_ir_actions_server +#: model:ir.cron,cron_name:website_sale_resource_booking.cron_expire +#: model:ir.cron,name:website_sale_resource_booking.cron_expire +msgid "Auto-cancel expired resource bookings" +msgstr "Autocancelar reservas/citas de recursos expiradas" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling_calendar +msgid "E-mail" +msgstr "Correo electrónico" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_resource_booking__expiration +msgid "Expiration" +msgstr "Expiración" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling_calendar +msgid "If unpaid, this pre-reservation will expire" +msgstr "Si no se paga a tiempo, esta prereserva expirará" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling_calendar +msgid "Name" +msgstr "Nombre" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling_calendar +msgid "Please indicate the attendee details:" +msgstr "Por favor indique los detalles del asistente:" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_product_product__resource_booking_timeout +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_product_template__resource_booking_timeout +msgid "Pre-booking timeout" +msgstr "Validez de las prereservas" + +#. module: website_sale_resource_booking +#: code:addons/website_sale_resource_booking/controllers/main.py:73 +#, python-format +msgid "Pre-schedule your booking (%(index)d of %(total)d)" +msgstr "Agende su prereserva (%(index)d de %(total)d)" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_resource_booking__prereserved_email +msgid "Prereserved Email" +msgstr "Correo electrónico prereservado" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_resource_booking__prereserved_name +msgid "Prereserved Name" +msgstr "Nombre prereservado" + +#. module: website_sale_resource_booking +#: model:ir.model,name:website_sale_resource_booking.model_product_template +msgid "Product Template" +msgstr "Plantilla de producto" + +#. module: website_sale_resource_booking +#: model:ir.model,name:website_sale_resource_booking.model_resource_booking +msgid "Resource Booking" +msgstr "Reserva de recursos" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_product_product__resource_booking_expiration +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_product_template__resource_booking_expiration +msgid "Resource Booking Expiration" +msgstr "Expiración de reservas/citas de recursos" + +#. module: website_sale_resource_booking +#: model:ir.model,name:website_sale_resource_booking.model_sale_order +msgid "Sale Order" +msgstr "Pedido de venta" + +#. module: website_sale_resource_booking +#: model:ir.model,name:website_sale_resource_booking.model_sale_order_line +msgid "Sales Order Line" +msgstr "Línea de pedido de venta" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.wizard_checkout +msgid "Schedule bookings" +msgstr "Agendar reservas/citas" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling +msgid "True" +msgstr "Verdadero" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,help:website_sale_resource_booking.field_product_product__resource_booking_timeout +#: model:ir.model.fields,help:website_sale_resource_booking.field_product_template__resource_booking_timeout +msgid "" +"When resources are pre-booked, the booking will expire after this timeout if " +"the quotation is not confirmed in time." +msgstr "" +"Cuando los recursos se prereservan, las reservas/citas expirarán tras este " +"periodo de validez si el presupuesto no se confirma a tiempo." + +#. module: website_sale_resource_booking +#: model:ir.model.fields,help:website_sale_resource_booking.field_resource_booking__expiration +msgid "" +"When will this booking expire if its related quotation is not confirmed in " +"time?" +msgstr "" +"¿Cuándo expirará esta reserva/cita, si su presupuesto vinculado no se " +"confirma a tiempo?" diff --git a/website_sale_resource_booking/i18n/website_sale_resource_booking.pot b/website_sale_resource_booking/i18n/website_sale_resource_booking.pot new file mode 100644 index 0000000000..c55269f000 --- /dev/null +++ b/website_sale_resource_booking/i18n/website_sale_resource_booking.pot @@ -0,0 +1,133 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_resource_booking +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.product +msgid "" +"\n" +" From the cart, you will be able to make a pre-reservation, which will expire" +msgstr "" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling +msgid "" +"\n" +" Return to Cart" +msgstr "" + +#. module: website_sale_resource_booking +#: model:ir.actions.server,name:website_sale_resource_booking.cron_expire_ir_actions_server +#: model:ir.cron,cron_name:website_sale_resource_booking.cron_expire +#: model:ir.cron,name:website_sale_resource_booking.cron_expire +msgid "Auto-cancel expired resource bookings" +msgstr "" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling_calendar +msgid "E-mail" +msgstr "" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_resource_booking__expiration +msgid "Expiration" +msgstr "" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling_calendar +msgid "If unpaid, this pre-reservation will expire" +msgstr "" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling_calendar +msgid "Name" +msgstr "" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling_calendar +msgid "Please indicate the attendee details:" +msgstr "" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_product_product__resource_booking_timeout +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_product_template__resource_booking_timeout +msgid "Pre-booking timeout" +msgstr "" + +#. module: website_sale_resource_booking +#: code:addons/website_sale_resource_booking/controllers/main.py:0 +#, python-format +msgid "Pre-schedule your booking (%(index)d of %(total)d)" +msgstr "" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_resource_booking__prereserved_email +msgid "Prereserved Email" +msgstr "" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_resource_booking__prereserved_name +msgid "Prereserved Name" +msgstr "" + +#. module: website_sale_resource_booking +#: model:ir.model,name:website_sale_resource_booking.model_product_template +msgid "Product Template" +msgstr "" + +#. module: website_sale_resource_booking +#: model:ir.model,name:website_sale_resource_booking.model_resource_booking +msgid "Resource Booking" +msgstr "" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_product_product__resource_booking_expiration +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_product_template__resource_booking_expiration +msgid "Resource Booking Expiration" +msgstr "" + +#. module: website_sale_resource_booking +#: model:ir.model,name:website_sale_resource_booking.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: website_sale_resource_booking +#: model:ir.model,name:website_sale_resource_booking.model_sale_order_line +msgid "Sales Order Line" +msgstr "" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.wizard_checkout +msgid "Schedule bookings" +msgstr "" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling +msgid "True" +msgstr "" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,help:website_sale_resource_booking.field_product_product__resource_booking_timeout +#: model:ir.model.fields,help:website_sale_resource_booking.field_product_template__resource_booking_timeout +msgid "" +"When resources are pre-booked, the booking will expire after this timeout if" +" the quotation is not confirmed in time." +msgstr "" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,help:website_sale_resource_booking.field_resource_booking__expiration +msgid "" +"When will this booking expire if its related quotation is not confirmed in " +"time?" +msgstr "" diff --git a/website_sale_resource_booking/models/__init__.py b/website_sale_resource_booking/models/__init__.py new file mode 100644 index 0000000000..b6534f8077 --- /dev/null +++ b/website_sale_resource_booking/models/__init__.py @@ -0,0 +1,4 @@ +from . import product_template +from . import resource_booking +from . import sale_order +from . import sale_order_line diff --git a/website_sale_resource_booking/models/product_template.py b/website_sale_resource_booking/models/product_template.py new file mode 100644 index 0000000000..289b773782 --- /dev/null +++ b/website_sale_resource_booking/models/product_template.py @@ -0,0 +1,34 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + resource_booking_timeout = fields.Float( + "Pre-booking timeout", + default=1, + help=( + "When resources are pre-booked, the booking will expire after " + "this timeout if the quotation is not confirmed in time." + ), + ) + resource_booking_expiration = fields.Datetime( + compute="_compute_resource_booking_expiration" + ) + + @api.depends("resource_booking_type_id", "resource_booking_timeout") + def _compute_resource_booking_expiration(self): + """When would the booking expire if placed right now.""" + self.resource_booking_expiration = False + now = fields.Datetime.now() + for one in self: + if not one.resource_booking_type_id: + continue + one.resource_booking_expiration = now + timedelta( + hours=one.resource_booking_timeout or 0 + ) diff --git a/website_sale_resource_booking/models/resource_booking.py b/website_sale_resource_booking/models/resource_booking.py new file mode 100644 index 0000000000..cf28f3965d --- /dev/null +++ b/website_sale_resource_booking/models/resource_booking.py @@ -0,0 +1,113 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.tests.common import Form + + +class ResourceBooking(models.Model): + _inherit = "resource.booking" + + expiration = fields.Datetime( + help=( + "When will this booking expire if its related quotation is " + "not confirmed in time?" + ) + ) + + # Temporary fields to avoid overloading database with res.partner records + # for abandoned eCommerce carts + prereserved_name = fields.Char() + prereserved_email = fields.Char() + + def _compute_access_url(self): + result = super()._compute_access_url() + index = self.env.context.get("checkout_booking_index") + if index and len(self) == 1: + self.access_url = "/shop/booking/%d" % index + return result + + def _confirm_prereservation(self): + """Convert prereservation data to actual partners, and confirm booking.""" + affected = self.with_context(dont_notify=True).filtered( + lambda booking: booking.prereserved_name and booking.prereserved_email + ) + for booking in affected: + company_id = self.env.context.get( + "force_company", + self.env.user.company_id.id, + ) + partner = self.env["res.partner"].search( + [ + ("email", "=ilike", booking.prereserved_email), + ("|"), + ("company_id", "=", False), + ("company_id", "=", company_id), + ], + limit=1, + ) + if not partner: + partner = self.env["res.partner"].create( + { + "name": booking.prereserved_name, + "email": booking.prereserved_email, + "company_id": company_id, + } + ) + booking.partner_id = partner.id + if booking.meeting_id: + booking.meeting_id.name = booking._get_name_formatted( + booking.partner_id, booking.type_id + ) + affected.write( + { + "expiration": False, + # Partners are already created, so this data is irrelevant now + "prereserved_email": False, + "prereserved_name": False, + # Anti-smartypants safety belt: rotate security token now + "access_token": False, + } + ) + # You're confirming some eCommerce sale, so confirm bookings directly + affected.action_confirm() + # Notify them + for booking in affected: + share_f = Form( + self.env["portal.share"].with_context( + active_id=booking.id, + active_ids=booking.ids, + active_model=booking._name, + default_note=booking.requester_advice, + default_partner_ids=[(4, booking.partner_id.id, 0)], + ) + ) + share = share_f.save() + # Put invitations in mail queue + share.with_context( + mail_notify_force_send=False, mail_create_nosubscribe=True + ).action_send_mail() + + @api.model + def _cron_cancel_expired(self, domain=None): + """Autocancel expired bookings.""" + domain = domain or [] + expired = self.with_context(no_mail_to_attendees=True).search( + [ + ("expiration", "<", fields.Datetime.now()), + ("state", "in", ("pending", "scheduled")), + ] + + domain + ) + expired.action_cancel() + + def action_cancel(self): + """Clean personal/cron data that you will never need again. + + Keeping this information without a clear purpose would incur into legal + obligations in some countries, so it's better to just dump it. + """ + self.write( + {"prereserved_name": False, "prereserved_email": False, "expiration": False} + ) + return super().action_cancel() diff --git a/website_sale_resource_booking/models/sale_order.py b/website_sale_resource_booking/models/sale_order.py new file mode 100644 index 0000000000..f6f93aa072 --- /dev/null +++ b/website_sale_resource_booking/models/sale_order.py @@ -0,0 +1,30 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + @api.onchange("partner_id") + def onchange_partner_id(self): + """Update bookings partner when user creates account in checkout wizard.""" + result = super().onchange_partner_id() + # Avoid sending calendar invites if user is in eCommerce checkout + _self = self.with_context(dont_notify=True) + for order in _self: + # We only care about eCommerce orders + if not order.website_id: + continue + for booking in order.resource_booking_ids: + website_partner = order.website_id.partner_id + if booking.partner_id != website_partner: + continue + # Update partner if it was the public user (which is usually inactive) + if booking.meeting_id: + booking.meeting_id.with_context( + active_test=False + ).partner_ids -= website_partner + booking.partner_id = order.partner_id + return result diff --git a/website_sale_resource_booking/models/sale_order_line.py b/website_sale_resource_booking/models/sale_order_line.py new file mode 100644 index 0000000000..3050b2ac32 --- /dev/null +++ b/website_sale_resource_booking/models/sale_order_line.py @@ -0,0 +1,59 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def _sync_resource_bookings(self): + """On eCommerce, a draft SO produces pending/scheduled bookings.""" + result = super()._sync_resource_bookings() + # Do not alter backend behavior + for line in self.with_context(active_test=False): + order = line.order_id + # We only care about eCommerce orders + if not order.website_id: + continue + bookings = line.resource_booking_ids + # If paid, create missing partners + if order.state == "sale": + bookings.with_company(order.company_id.id)._confirm_prereservation() + continue + # Continue if it is not an eCommerce cart + if ( + order.state != "draft" + and order.env.context.get("website_id") == order.website_id.id + ): + continue + # It is still a cart, so let's create pending bookings + values = { + "expiration": line.product_id.resource_booking_expiration, + "sale_order_line_id": line.id, + "type_id": line.product_id.resource_booking_type_id.id, + } + rbc_rel = line.product_id.resource_booking_type_combination_rel_id + context = { + "default_partner_id": line.order_id.partner_id.id, + "default_combination_auto_assign": not rbc_rel, + "default_combination_id": rbc_rel.combination_id.id, + } + # Assign prereservation data if user is logged in + prereserved_partner = order.partner_id - order.website_id.user_id.partner_id + if prereserved_partner: + context.update( + { + "default_prereserved_name": prereserved_partner.name, + "default_prereserved_email": prereserved_partner.email, + } + ) + # Add/remove bookings if needed + self.env["resource.booking"]._cron_cancel_expired( + [("id", "in", bookings.ids)] + ) + expected_amount = int(line.product_uom_qty) if values["type_id"] else 0 + self.with_context(**context)._add_or_cancel_bookings( + bookings, expected_amount, values + ) + return result diff --git a/website_sale_resource_booking/readme/CONTRIBUTORS.rst b/website_sale_resource_booking/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..bdf9d224a9 --- /dev/null +++ b/website_sale_resource_booking/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Tecnativa `_: + + * Jairo Llopis + * Stefan Ungureanu diff --git a/website_sale_resource_booking/readme/DESCRIPTION.rst b/website_sale_resource_booking/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..3329132690 --- /dev/null +++ b/website_sale_resource_booking/readme/DESCRIPTION.rst @@ -0,0 +1,5 @@ +This module extends the functionality of ``sale_resource_booking`` to support +the eCommerce use case and to allow your visitors to buy products that produce +a resource booking, and pre-book them before buying. + +You can also set a timeout for those pre-bookings to expire if unpaid. diff --git a/website_sale_resource_booking/readme/INSTALL.rst b/website_sale_resource_booking/readme/INSTALL.rst new file mode 100644 index 0000000000..575f995846 --- /dev/null +++ b/website_sale_resource_booking/readme/INSTALL.rst @@ -0,0 +1,4 @@ +To install this module, you need these dependencies: + +* ``resource_booking`` from https://github.com/OCA/calendar +* ``sale_resource_booking`` from https://github.com/OCA/sale-workflow diff --git a/website_sale_resource_booking/readme/USAGE.rst b/website_sale_resource_booking/readme/USAGE.rst new file mode 100644 index 0000000000..e544f94b13 --- /dev/null +++ b/website_sale_resource_booking/readme/USAGE.rst @@ -0,0 +1,20 @@ +To use this module, you need to know how to use ``sale_resource_booking`` and +``resource_booking``. This document doesn't explain the details for those +related modules. + +All products that you link to a resource booking type will allow pre-bookings +if sold from your eCommerce. To configure those pre-bookings timeout: + +#. Go to the product form in the backend. +#. Use the *Resource booking timeout* field, in the *Sales* tab. + +When you go to that product's eCommerce page, you'll see a little message above +the *Add to cart* button, telling the user that they will be able to pre-book it +before buying. + +When you add to your cart one (or more) bookable products, you will see in the +eCommerce checkout wizard a new step that you will have to follow to be able to +buy. This step will display a calendar with bookable slots for you to choose. + +When you are redirected to payment, make sure to pay before your pre-bookings +expire! diff --git a/website_sale_resource_booking/static/description/icon.png b/website_sale_resource_booking/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/website_sale_resource_booking/static/description/icon.png differ diff --git a/website_sale_resource_booking/static/description/index.html b/website_sale_resource_booking/static/description/index.html new file mode 100644 index 0000000000..3bd4261501 --- /dev/null +++ b/website_sale_resource_booking/static/description/index.html @@ -0,0 +1,458 @@ + + + + + + +Sell resource booking products in your eCommerce + + + +
+

Sell resource booking products in your eCommerce

+ + +

Beta License: AGPL-3 OCA/e-commerce Translate me on Weblate Try me on Runboat

+

This module extends the functionality of sale_resource_booking to support +the eCommerce use case and to allow your visitors to buy products that produce +a resource booking, and pre-book them before buying.

+

You can also set a timeout for those pre-bookings to expire if unpaid.

+

Table of contents

+ +
+

Installation

+

To install this module, you need these dependencies:

+ +
+
+

Usage

+

To use this module, you need to know how to use sale_resource_booking and +resource_booking. This document doesn’t explain the details for those +related modules.

+

All products that you link to a resource booking type will allow pre-bookings +if sold from your eCommerce. To configure those pre-bookings timeout:

+
    +
  1. Go to the product form in the backend.
  2. +
  3. Use the Resource booking timeout field, in the Sales tab.
  4. +
+

When you go to that product’s eCommerce page, you’ll see a little message above +the Add to cart button, telling the user that they will be able to pre-book it +before buying.

+

When you add to your cart one (or more) bookable products, you will see in the +eCommerce checkout wizard a new step that you will have to follow to be able to +buy. This step will display a calendar with bookable slots for you to choose.

+

When you are redirected to payment, make sure to pay before your pre-bookings +expire!

+
+
+

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

+
    +
  • Tecnativa
  • +
+
+
+

Contributors

+
    +
  • Tecnativa:
      +
    • Jairo Llopis
    • +
    • Stefan Ungureanu
    • +
    +
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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

+

Current maintainer:

+

Yajo

+

This module is part of the OCA/e-commerce project on GitHub.

+

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

+
+
+
+ + diff --git a/website_sale_resource_booking/static/src/css/website_sale_resource_booking.scss b/website_sale_resource_booking/static/src/css/website_sale_resource_booking.scss new file mode 100644 index 0000000000..01511396d1 --- /dev/null +++ b/website_sale_resource_booking/static/src/css/website_sale_resource_booking.scss @@ -0,0 +1,15 @@ +/* Copyright 2021 Tecnativa - Jairo Llopis + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +.o_wizard_has_bookings + .progress-wizard { + .progress-wizard-step { + @include media-breakpoint-up(md) { + // 4 steps to buy + width: percentage(1/4); + + .o_wizard_has_extra_step + & { + width: percentage(1/5); + } + } + } +} diff --git a/website_sale_resource_booking/static/src/js/tour_checkout.js b/website_sale_resource_booking/static/src/js/tour_checkout.js new file mode 100644 index 0000000000..a55c8401aa --- /dev/null +++ b/website_sale_resource_booking/static/src/js/tour_checkout.js @@ -0,0 +1,240 @@ +/* Copyright 2021 Tecnativa - Jairo Llopis + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +odoo.define("website_sale_resource_booking.tour_checkout", function (require) { + "use strict"; + + var tour = require("web_tour.tour"); + + tour.register( + "website_sale_resource_booking_checkout", + { + url: "/shop?search=test not bookable product", + test: true, + }, + [ + // Add non-bookable product, to make sure we don't interfere + { + trigger: ".oe_product_cart a:contains('test not bookable product')", + }, + { + trigger: "#add_to_cart", + }, + { + trigger: ".oe_search_box", + run: "text test bookable product", + }, + { + trigger: ".oe_search_button", + }, + // Select bookable product + { + trigger: ".oe_product_cart a:contains('test bookable product')", + }, + { + // Make sure it displays the booking message + extra_trigger: + ".alert-info:containsTextLike('From the cart, you will be able to make a pre-reservation, which will expire in 1 hour')", + // Add one more + trigger: ".css_quantity .fa-plus", + }, + // When there's 2 products, add another one + { + extra_trigger: ".css_quantity .quantity:propValue('2')", + trigger: ".css_quantity .fa-plus", + }, + // When there's 3 products, add to cart + { + extra_trigger: ".css_quantity .quantity:propValue('3')", + trigger: "#add_to_cart", + }, + { + trigger: "a[href='/shop/cart']", + }, + { + // Check there's a booking step advertised in the checkout wizard + extra_trigger: + ".progress-wizard-step.disabled:contains('Schedule bookings')", + // Go to next step + trigger: ".oe_cart .btn:contains('Process Checkout')", + }, + // Booking 1 of 3 + { + extra_trigger: [ + ".oe_website_sale", + // Check we're in the correct booking step + ":has(.progress-wizard-step.active:contains('Schedule bookings'))", + ":has(h3:contains('Pre-schedule your booking (1 of 3)'))", + // We're using freezegun, so date is hardcoded + ":has(.o_booking_calendar:contains('February 2021'))", + ].join(""), + // No free slots on February, go to March as suggested + trigger: + ".alert-danger:contains('No free slots found this month.') a:contains('Try next month')", + }, + { + extra_trigger: [ + ".oe_website_sale", + // Check we're in the correct booking step + ":has(.progress-wizard-step.active:contains('Schedule bookings'))", + ":has(h3:contains('Pre-schedule your booking (1 of 3)'))", + ":has(.o_booking_calendar:contains('March 2021'))", + ].join(""), + // Open dropdown for March 1st + trigger: "#dropdown-trigger-2021-03-01", + }, + // Select 09:00 + { + trigger: + ".dropdown:has(#dropdown-trigger-2021-03-01) .dropdown-menu button:contains('09:00')", + }, + // Enter Mr. A details, and confirm + { + trigger: ".modal-dialog input[name=partner_name]", + run: "text Mr. A", + }, + { + trigger: ".modal-dialog input[name=partner_email]", + run: "text mr.a@example.com", + }, + { + // Check we have an alert about payment timeout + extra_trigger: + ".alert-warning:containsTextLike('If unpaid, this pre-reservation will expire in 1 hour')", + trigger: ".modal-dialog .btn:contains('Confirm booking')", + }, + // Booking 2 of 3 (almost same as above) + { + extra_trigger: [ + ".oe_website_sale", + ":has(.progress-wizard-step.active:contains('Schedule bookings'))", + ":has(h3:contains('Pre-schedule your booking (2 of 3)'))", + ":has(.o_booking_calendar:contains('February 2021'))", + ].join(""), + trigger: + ".alert-danger:contains('No free slots found this month.') a:contains('Try next month')", + }, + { + extra_trigger: [ + ".oe_website_sale", + ":has(.progress-wizard-step.active:contains('Schedule bookings'))", + ":has(h3:contains('Pre-schedule your booking (2 of 3)'))", + ":has(.o_booking_calendar:contains('March 2021'))", + ].join(""), + trigger: "#dropdown-trigger-2021-03-01", + }, + { + trigger: + ".dropdown:has(#dropdown-trigger-2021-03-01) .dropdown-menu button:contains('09:00')", + }, + // Enter Mr. B details, and confirm + { + trigger: ".modal-dialog input[name=partner_name]", + run: "text Mr. B", + }, + { + trigger: ".modal-dialog input[name=partner_email]", + run: "text mr.b@example.com", + }, + { + extra_trigger: + ".alert-warning:containsTextLike('If unpaid, this pre-reservation will expire in 1 hour')", + trigger: ".modal-dialog .btn:contains('Confirm booking')", + }, + // Booking 3 of 3 + { + extra_trigger: [ + ".oe_website_sale", + ":has(.progress-wizard-step.active:contains('Schedule bookings'))", + ":has(h3:contains('Pre-schedule your booking (3 of 3)'))", + ":has(.o_booking_calendar:contains('February 2021'))", + ].join(""), + trigger: + ".alert-danger:contains('No free slots found this month.') a:contains('Try next month')", + }, + { + extra_trigger: [ + ".oe_website_sale", + ":has(.progress-wizard-step.active:contains('Schedule bookings'))", + ":has(h3:contains('Pre-schedule your booking (3 of 3)'))", + ":has(.o_booking_calendar:contains('March 2021'))", + ":has(tfoot:containsTextLike('All times are displayed using this timezone: UTC'))", + ].join(""), + trigger: "#dropdown-trigger-2021-03-01", + }, + { + // This time 09:00 is full because RBT has only 2 RBC available, and thus we can't see it + extra_trigger: + ".dropdown:has(#dropdown-trigger-2021-03-01) .dropdown-menu:not(:has(button:contains('09:00')))", + trigger: + ".dropdown:has(#dropdown-trigger-2021-03-01) .dropdown-menu button:contains('09:30')", + }, + // Enter Mr. C details, and confirm + { + trigger: ".modal-dialog input[name=partner_name]", + run: "text Mr. C", + }, + { + trigger: ".modal-dialog input[name=partner_email]", + run: "text mr.c@example.com", + }, + { + extra_trigger: + ".alert-warning:containsTextLike('If unpaid, this pre-reservation will expire in 1 hour')", + trigger: ".modal-dialog .btn:contains('Confirm booking')", + }, + // Fill buyer address + { + trigger: ".oe_website_sale input[name=phone]", + run: "text +32 485 118.218", + }, + { + trigger: ".oe_website_sale input[name=street]", + run: "text Street A", + }, + { + trigger: ".oe_website_sale input[name=city]", + run: "text City A", + }, + { + trigger: ".oe_website_sale input[name=zip]", + run: "text 18503", + }, + { + trigger: ".oe_website_sale select[name=country_id]", + run: "text Fiji", + }, + { + trigger: ".oe_website_sale", + run: function () { + // Integration with website_sale_vat_required + $(".oe_website_sale input[name=vat]").val("US01234567891"); + // Integration with website_sale_require_legal + $(".oe_website_sale input[name=accepted_legal_terms]").prop( + "checked", + true + ); + }, + }, + { + trigger: ".oe_website_sale .btn:contains('Next')", + }, + { + trigger: "a[href='/shop/confirm_order']", + }, + { + trigger: ".oe_website_sale .btn:contains('Pay Now')", + }, + { + trigger: '#payment_method label:contains("Wire Transfer")', + }, + // No need to wait for payment for this test case; that's tested elsewhere + { + extra_trigger: + '#payment_method label:contains("Wire Transfer") input:checked,#payment_method:not(:has("input:radio:visible"))', + trigger: + 'button[name="o_payment_submit_button"]:visible:not(:disabled)', + }, + ] + ); +}); diff --git a/website_sale_resource_booking/templates/website_sale.xml b/website_sale_resource_booking/templates/website_sale.xml new file mode 100644 index 0000000000..2c95bef3e8 --- /dev/null +++ b/website_sale_resource_booking/templates/website_sale.xml @@ -0,0 +1,160 @@ + + + + + + + + + diff --git a/website_sale_resource_booking/tests/__init__.py b/website_sale_resource_booking/tests/__init__.py new file mode 100644 index 0000000000..6dab214ac8 --- /dev/null +++ b/website_sale_resource_booking/tests/__init__.py @@ -0,0 +1 @@ +from . import test_ui diff --git a/website_sale_resource_booking/tests/test_ui.py b/website_sale_resource_booking/tests/test_ui.py new file mode 100644 index 0000000000..f82e08b3e1 --- /dev/null +++ b/website_sale_resource_booking/tests/test_ui.py @@ -0,0 +1,124 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import time +from datetime import datetime + +from freezegun import freeze_time + +from odoo.tests.common import Form, HttpCase, tagged + +from ...resource_booking.tests.common import create_test_data + + +@freeze_time("2021-02-26 09:00:00", tick=True) +@tagged("post_install", "-at_install") +class UICase(HttpCase): + def setUp(self): + super().setUp() + create_test_data(self) + self.product = self.env["product.product"].create( + { + "list_price": 100, + "name": "test bookable product", + "resource_booking_type_id": self.rbt.id, + "website_published": True, + } + ) + self.normal_product = self.env["product.product"].create( + { + "list_price": 50, + "name": "test not bookable product", + "website_published": True, + } + ) + # If the created user has the same name as the invited users, + # the invitation does not reach the user. + self.user = self.env["res.users"].create( + { + "name": "user", + "email": "test@example.com", + "login": "booking_test_user", + "password": "booking_test_user", + "groups_id": [(4, self.env.ref("base.group_user").id, 0)], + } + ) + # Clean up pending emails, to avoid polluting tests + self.env["mail.mail"].search([("state", "=", "outgoing")]).unlink() + + def test_checkout(self): + """Booking checkout tour.""" + # A visitor called Mr. A buys 3 booking products + self.start_tour( + "/shop?search=test not bookable product", + "website_sale_resource_booking_checkout", + login="booking_test_user", + ) # Find Mr. A's cart + so = self.env["sale.order"].search([("partner_id", "=", "user")]) + bookings = so.resource_booking_ids + # It's linked to 3 scheduled bookings, that belong to him + self.assertEqual(len(bookings), 3) + self.assertEqual(bookings.mapped("state"), ["scheduled"] * 3) + self.assertEqual(bookings.mapped("partner_id"), so.partner_id) + # Confirm sale (which would happen automatically if paid online) + so.action_confirm() + # Now the 3 bookings are linked to the partners filled at checkout + self.assertEqual( + set(bookings.mapped("partner_id.name")), {"Mr. A", "Mr. B", "Mr. C"} + ) + self.assertEqual( + set(bookings.mapped("partner_id.email")), + {"mr.a@example.com", "mr.b@example.com", "mr.c@example.com"}, + ) + # The mail queue, later, will send the expected notifications to see + # resource bookings in portal, but not to event attendance + pending_mails = self.env["mail.mail"].search([("state", "=", "outgoing")]) + self.assertGreaterEqual( + set(pending_mails.mapped("subject")), + { + # Calendar invitations with attached .ics file + "Invitation to Mr. A - Test resource booking type", + "Invitation to Mr. B - Test resource booking type", + "Invitation to Mr. C - Test resource booking type", + # Portal invitations with tokenized link + "You are invited to access Mr. A - Test resource booking type " + "- 03/01/2021 at (09:00:00 To 09:30:00) (UTC)", + "You are invited to access Mr. B - Test resource booking type " + "- 03/01/2021 at (09:00:00 To 09:30:00) (UTC)", + "You are invited to access Mr. C - Test resource booking type " + "- 03/01/2021 at (09:30:00 To 10:00:00) (UTC)", + }, + ) + + def test_expiration_cron(self): + """Abandoned cart expires bookings.""" + website = self.env["website"].get_current_website() + cron = self.browse_ref("website_sale_resource_booking.cron_expire") + # Set product expiration to 2 second (approx... you know... floats) + self.product.resource_booking_timeout = 2 / 60 / 60 + # Emulate a cart + order = ( + self.env["sale.order"] + .with_context(website_id=website.id) + .create( + { + "website_id": website.id, + "partner_id": self.partner.id, + "order_line": [ + (0, 0, {"product_id": self.product.id, "product_uom_qty": 2}) + ], + } + ) + ) + self.assertEqual(len(order.resource_booking_ids), 2) + # Emulate the user prereserved both bookings + dt = datetime(2021, 3, 1, 9) + bookings = order.resource_booking_ids + for booking in bookings: + with Form(booking) as booking_f: + booking_f.start = dt + self.assertEqual(bookings.mapped("state"), ["scheduled"] * 2) + # Expiration cron does its job + time.sleep(3) + cron.method_direct_trigger() + self.assertEqual(bookings.mapped("state"), ["canceled"] * 2) diff --git a/website_sale_resource_booking/views/product_template_view.xml b/website_sale_resource_booking/views/product_template_view.xml new file mode 100644 index 0000000000..a516953f13 --- /dev/null +++ b/website_sale_resource_booking/views/product_template_view.xml @@ -0,0 +1,23 @@ + + + + + Display booking expiration + product.template + + + + + + + + +