diff --git a/website_sale_resource_booking/README.rst b/website_sale_resource_booking/README.rst new file mode 100644 index 0000000000..7b749bd89d --- /dev/null +++ b/website_sale_resource_booking/README.rst @@ -0,0 +1,126 @@ +================================================ +Sell resource booking products in your eCommerce +================================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:9b6b3d77f1d829501674dd9c406dfc235fe5419aa62949514db588d608f5e484 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/18.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-18-0/e-commerce-18-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/builds?repo=OCA/e-commerce&target_branch=18.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: + +1. Go to the product form in the backend. +2. 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 to smash 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..e109d07a23 --- /dev/null +++ b/website_sale_resource_booking/__manifest__.py @@ -0,0 +1,24 @@ +# 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": "18.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_tests": [ + "/website_sale_resource_booking/static/src/js/tour_checkout.esm.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..a9f1b48573 --- /dev/null +++ b/website_sale_resource_booking/controllers/main.py @@ -0,0 +1,133 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from datetime import timezone +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 ...website_sale.controllers import main + + +class WebsiteSale(main.WebsiteSale): + def _get_bookings(self): + """Obtain bookings from current cart.""" + order = request.website.sale_get_order() + order = order.with_context(active_test=False) + 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 _booking_redirection(self, booking, index): + """Call this method in /schedule and /confirm to redirect if + the booking has expired. + """ + if not booking.active: + msg = _("Booking has expired") + url = f"/shop/booking/{index}/schedule?error={quote_plus(msg)}" + booking.sale_order_line_id._sync_resource_bookings() # re-active + return request.redirect(url) + + def _check_cart(self, order_sudo): + """Redirect to scheduling bookings if still not done.""" + order_sudo.order_line._sync_resource_bookings() + bookings = order_sudo.mapped("order_line.resource_booking_ids").filtered( + lambda r: r.state == "pending" + ) + if bookings: + return request.redirect("/shop/booking/1/schedule") + return super()._check_cart(order_sudo) + + @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") + redirection = self._booking_redirection(booking, index) + if redirection: + return redirection + 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, + ) + ) + if not booking_sudo: + return request.redirect("/shop/checkout") + redirection = self._booking_redirection(booking_sudo, index) + if redirection: + return redirection + when_tz_aware = isoparse(when) + when_naive = when_tz_aware.astimezone(timezone.utc).replace(tzinfo=None) + try: + booking_sudo.start = when_naive + except ValidationError as error: + url = f"/shop/booking/{index}/schedule?error={quote_plus(str(error))}" + 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(f"/shop/booking/{index + 1}/schedule") 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..e42e66960a --- /dev/null +++ b/website_sale_resource_booking/data/ir_cron_data.xml @@ -0,0 +1,14 @@ + + + + + Auto-cancel expired resource bookings + + code + model._cron_cancel_expired() + + 1 + minutes + + diff --git a/website_sale_resource_booking/i18n/es.po b/website_sale_resource_booking/i18n/es.po new file mode 100644 index 0000000000..ec1357a6a0 --- /dev/null +++ b/website_sale_resource_booking/i18n/es.po @@ -0,0 +1,150 @@ +# 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-10-09 07:57+0000\n" +"Last-Translator: Ivorra78 \n" +"Language-Team: \n" +"Language: 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: Weblate 4.17\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:0 +#, 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 "Sales Order" +msgstr "Orden 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?" + +#~ msgid "Sale Order" +#~ msgstr "Pedido de venta" diff --git a/website_sale_resource_booking/i18n/pt_BR.po b/website_sale_resource_booking/i18n/pt_BR.po new file mode 100644 index 0000000000..990884a725 --- /dev/null +++ b/website_sale_resource_booking/i18n/pt_BR.po @@ -0,0 +1,145 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_resource_booking +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-04-26 15:36+0000\n" +"Last-Translator: \"Augusto D. Lisbôa\" \n" +"Language-Team: none\n" +"Language: pt_BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.17\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" +" No carrinho você poderá fazer uma pré-reserva, que irá " +"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" +" Voltar ao carrinho" + +#. 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 "Cancelar automaticamente reservas expiradas" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling_calendar +msgid "E-mail" +msgstr "E-mail" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_resource_booking__expiration +msgid "Expiration" +msgstr "Expiração" + +#. 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 "Se não for paga, esta pré-reserva irá expirar" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling_calendar +msgid "Name" +msgstr "Nome" + +#. 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 "Indique os dados do participante:" + +#. 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 "Tempo limite de pré-reserva" + +#. 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 "Pré-agende sua reserva (%(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 "Email da pré-reserva" + +#. module: website_sale_resource_booking +#: model:ir.model.fields,field_description:website_sale_resource_booking.field_resource_booking__prereserved_name +msgid "Prereserved Name" +msgstr "Nome da pré-reserva" + +#. module: website_sale_resource_booking +#: model:ir.model,name:website_sale_resource_booking.model_product_template +msgid "Product Template" +msgstr "Template do Produto" + +#. module: website_sale_resource_booking +#: model:ir.model,name:website_sale_resource_booking.model_resource_booking +msgid "Resource Booking" +msgstr "Agendamento do Recurso" + +#. 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 "Expiração do agendamento" + +#. module: website_sale_resource_booking +#: model:ir.model,name:website_sale_resource_booking.model_sale_order +msgid "Sales Order" +msgstr "Pedido de Venda" + +#. module: website_sale_resource_booking +#: model:ir.model,name:website_sale_resource_booking.model_sale_order_line +msgid "Sales Order Line" +msgstr "Linha do Pedido de Venda" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.wizard_checkout +msgid "Schedule bookings" +msgstr "Agendar reservas" + +#. module: website_sale_resource_booking +#: model_terms:ir.ui.view,arch_db:website_sale_resource_booking.scheduling +msgid "True" +msgstr "Verdadeiro" + +#. 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 "" +"O agendamento da pré-reserva expirará após este tempo caso o orçamento não " +"seja confirmado." + +#. 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 "" +"Quando esta reserva expirará se a cotação relacionada não for confirmada a " +"tempo?" 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..8ede4f7dc7 --- /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 15.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..c19c3be4c1 --- /dev/null +++ b/website_sale_resource_booking/models/__init__.py @@ -0,0 +1,5 @@ +from . import product_template +from . import resource_booking +from . import sale_order +from . import sale_order_line +from . import website 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..e7f55d5e7a --- /dev/null +++ b/website_sale_resource_booking/models/resource_booking.py @@ -0,0 +1,109 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +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_vals = { + "note": booking.requester_advice, + "partner_ids": [(4, booking.partner_id.id)], + "res_model": booking._name, + "res_id": booking.id, + } + share = self.env["portal.share"].create(share_vals) + # 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..94542ccff1 --- /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_warning(self): + """Update booking partner when user creates accounts in checkout wizard.""" + result = super()._onchange_partner_id_warning() + # Avoid sending calendar invites if a 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/models/website.py b/website_sale_resource_booking/models/website.py new file mode 100644 index 0000000000..a67e73b9f6 --- /dev/null +++ b/website_sale_resource_booking/models/website.py @@ -0,0 +1,27 @@ +# Copyright 2025 Tecnativa - Víctor Martínez +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import _lt, models + + +class Website(models.Model): + _inherit = "website" + + def _get_checkout_step_list(self): + steps = super()._get_checkout_step_list() + order = self.sale_get_order() + if order.mapped("order_line.product_id.resource_booking_type_id"): + booking_step_structure = { + "name": _lt("Schedule bookings"), + "current_href": "/shop/booking/1/schedule", + "back_button": _lt("Back to cart"), + "back_button_href": "/shop/cart", + } + steps.insert( + 1, ("website_sale_resource_booking.scheduling", booking_step_structure) + ) + steps[0][1]["main_button"] = booking_step_structure["name"] + steps[0][1]["main_button_href"] = booking_step_structure["current_href"] + steps[2][1]["back_button"] = booking_step_structure["name"] + steps[2][1]["back_button_href"] = booking_step_structure["current_href"] + return steps diff --git a/website_sale_resource_booking/pyproject.toml b/website_sale_resource_booking/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/website_sale_resource_booking/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_sale_resource_booking/readme/CONTRIBUTORS.md b/website_sale_resource_booking/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..3541fd95f3 --- /dev/null +++ b/website_sale_resource_booking/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [Tecnativa](https://www.tecnativa.com): + - Jairo Llopis + - Stefan Ungureanu diff --git a/website_sale_resource_booking/readme/DESCRIPTION.md b/website_sale_resource_booking/readme/DESCRIPTION.md new file mode 100644 index 0000000000..0ddbdd7846 --- /dev/null +++ b/website_sale_resource_booking/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +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.md b/website_sale_resource_booking/readme/INSTALL.md new file mode 100644 index 0000000000..32da9a56f6 --- /dev/null +++ b/website_sale_resource_booking/readme/INSTALL.md @@ -0,0 +1,4 @@ +To install this module, you need these dependencies: + +- `resource_booking` from +- `sale_resource_booking` from diff --git a/website_sale_resource_booking/readme/USAGE.md b/website_sale_resource_booking/readme/USAGE.md new file mode 100644 index 0000000000..3f096fc2ca --- /dev/null +++ b/website_sale_resource_booking/readme/USAGE.md @@ -0,0 +1,22 @@ +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. 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..d8885acaa4 --- /dev/null +++ b/website_sale_resource_booking/static/description/index.html @@ -0,0 +1,465 @@ + + + + + +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 to smash 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/js/tour_checkout.esm.js b/website_sale_resource_booking/static/src/js/tour_checkout.esm.js new file mode 100644 index 0000000000..fcc44eaac2 --- /dev/null +++ b/website_sale_resource_booking/static/src/js/tour_checkout.esm.js @@ -0,0 +1,177 @@ +/** @odoo-module */ +/* Copyright 2021 Tecnativa - Jairo Llopis + Copyright 2025 Tecnativa - Víctor Martínez + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {registry} from "@web/core/registry"; +import * as tourUtils from "@website_sale/js/tours/tour_utils"; + +registry.category("web_tour.tours").add("website_sale_resource_booking", { + url: "/shop", + steps: () => [ + ...tourUtils.searchProduct("test not bookable product"), + { + content: "select test not bookable product", + trigger: '.oe_product_cart:first a:contains("test not bookable product")', + run: "click", + }, + { + content: "click on add to cart", + trigger: '#product_detail form[action^="/shop/cart/update"] #add_to_cart', + run: "click", + }, + ...tourUtils.searchProduct("test bookable product"), + { + trigger: 'form input[name="search"]', + run: "edit test bookable product", + }, + { + trigger: 'form:has(input[name="search"]) .oe_search_button', + }, + { + content: "select test bookable product", + trigger: '.oe_product_cart:first a:contains("test bookable product")', + run: "click", + }, + { + content: "change quantity", + trigger: + '#product_detail form[action^="/shop/cart/update"] input[name=add_qty]', + run: "edit 3", + }, + { + content: "click on add to cart", + trigger: '#product_detail form[action^="/shop/cart/update"] #add_to_cart', + run: "click", + }, + + tourUtils.goToCart({quantity: 4}), + { + // Go to next step + trigger: "a[name='website_sale_main_button']:contains('Schedule bookings')", + run: "click", + }, + { + content: "Calendar is loaded", + trigger: ".o_booking_calendar", + }, + { + content: "Validate calendar title is shown", + trigger: "h3:contains('Pre-schedule your booking')", + }, + { + content: "Try next month if no slots", + trigger: ".alert-danger a:contains('Try next month')", + run: "click", + }, + { + content: "Open March 1 calendar dropdown", + trigger: "#dropdown-trigger-2021-03-01", + run: "click", + }, + { + trigger: + ".dropdown:has(#dropdown-trigger-2021-03-01) .dropdown-menu button:contains('09:00')", + run: "click", + }, + { + trigger: + '.modal.show input[name="partner_name"], .modal input[name="partner_name"]', + run: "edit Mr. A", + }, + { + trigger: + '.modal.show input[name="partner_email"], .modal input[name="partner_email"], .modal input[name="email"]', + run: "edit mr.a@example.com", + }, + { + trigger: ".modal-dialog .btn:contains('Confirm booking')", + run: "click", + }, + // Booking 2 of 3 (almost same as above) + { + content: "Try next month if no slots", + trigger: ".alert-danger a:contains('Try next month')", + run: "click", + }, + { + content: "Open March 1 calendar dropdown", + trigger: "#dropdown-trigger-2021-03-01", + run: "click", + }, + + { + trigger: + ".dropdown:has(#dropdown-trigger-2021-03-01) .dropdown-menu button:contains('09:00')", + run: "click", + }, + // Enter Mr. B details, and confirm + { + trigger: + '.modal.show input[name="partner_name"], .modal input[name="partner_name"]', + run: "edit Mr. B", + }, + { + trigger: + '.modal.show input[name="partner_email"], .modal input[name="partner_email"], .modal input[name="email"]', + run: "edit mr.b@example.com", + }, + { + trigger: ".modal-dialog .btn:contains('Confirm booking')", + run: "click", + }, + { + content: "Try next month if no slots", + trigger: ".alert-danger a:contains('Try next month')", + run: "click", + }, + { + content: "Open March 1 calendar dropdown", + trigger: "#dropdown-trigger-2021-03-01", + run: "click", + }, + + { + trigger: + ".dropdown:has(#dropdown-trigger-2021-03-01) .dropdown-menu button:contains('09:30')", + run: "click", + }, + // Enter Mr. B details, and confirm + { + trigger: + '.modal.show input[name="partner_name"], .modal input[name="partner_name"]', + run: "edit Mr. C", + }, + { + trigger: + '.modal.show input[name="partner_email"], .modal input[name="partner_email"], .modal input[name="email"]', + run: "edit mr.c@example.com", + }, + { + trigger: ".modal-dialog .btn:contains('Confirm booking')", + run: "click", + }, + tourUtils.goToCheckout(), + { + trigger: ".oe_website_sale", + run: function () { + $('input[name="phone"]').val("+32 485 118.218"); + $('input[name="street"]').val("Street A"); + $('input[name="city"]').val("City A"); + $('input[name="zip"]').val("18503"); + $("#country_id option:eq(1)").attr("selected", true); + // Integration with website_sale_vat_required + $('input[name="vat"]').val("US01234567891"); + // Integration with website_sale_require_legal + $(".oe_website_sale input[name=accepted_legal_terms]").prop( + "checked", + true + ); + }, + }, + { + trigger: ".btn-primary:contains('Save address')", + }, + tourUtils.goToCheckout(), + ], +}); 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..d50cc74c76 --- /dev/null +++ b/website_sale_resource_booking/templates/website_sale.xml @@ -0,0 +1,118 @@ + + + + + + + + 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..1e2bc752b9 --- /dev/null +++ b/website_sale_resource_booking/tests/test_ui.py @@ -0,0 +1,219 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# Copyright 2025 Tecnativa - Víctor Martínez +# 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 import Command +from odoo.tests import HttpCase, new_test_user, tagged +from odoo.tools import mute_logger + +from odoo.addons.base.tests.common import DISABLED_MAIL_CONTEXT +from odoo.addons.website.tools import MockRequest + +from ...resource_booking.tests.common import create_test_data +from ...website_sale_resource_booking.controllers.main import WebsiteSale + + +@tagged("post_install", "-at_install") +class UICase(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, **DISABLED_MAIL_CONTEXT)) + create_test_data(cls) + cls.public_customer = cls.env["res.partner"].create({"name": "Customer X"}) + cls.product = cls.env["product.product"].create( + { + "list_price": 100, + "name": "test bookable product", + "resource_booking_type_id": cls.rbt.id, + "website_published": True, + } + ) + cls.normal_product = cls.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. + cls.user = new_test_user(cls.env, login="booking_test_user") + # Clean up pending emails, to avoid polluting tests + cls.env["mail.mail"].search([("state", "=", "outgoing")]).unlink() + + def setUp(self): + super().setUp() + self.ResourceBookingController = WebsiteSale() + + @freeze_time("2021-02-26 09:00:00", tick=True) + def test_checkout(self): + """Booking checkout tour.""" + # A visitor called Mr. A buys 3 booking products + self.start_tour( + "/shop", + "website_sale_resource_booking", + login="booking_test_user", + ) + # Find Mr. A's cart + so = self.env["sale.order"].search( + [("partner_id", "=", self.user.partner_id.id)] + ) + 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 + so._onchange_partner_id_warning() + 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"), + ("subject", "not ilike", "Pending Order"), + ] + ) + actual_invitations = { + subject.strip() + for subject in pending_mails.mapped("subject") + if subject.startswith("Invitation to access") + } + expected_invitations = { + "Invitation to access Mr. A - Test resource booking type - " + "03/01/2021 at (09:00:00 To 09:30:00) (UTC)", + "Invitation to access Mr. B - Test resource booking type - " + "03/01/2021 at (09:00:00 To 09:30:00) (UTC)", + "Invitation to access Mr. C - Test resource booking type - " + "03/01/2021 at (09:30:00 To 10:00:00) (UTC)", + } + self.assertGreaterEqual(actual_invitations, expected_invitations) + + @mute_logger("odoo.models.unlink") + 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": [ + Command.create( + {"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: + booking.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) + + def test_booking_redirection_on_inactive_booking(self): + website = self.env["website"].get_current_website() + with MockRequest(self.env, website=website): + order = ( + self.env["sale.order"] + .with_context(website_id=website.id) + .create( + { + "website_id": website.id, + "partner_id": self.partner.id, + "order_line": [ + Command.create( + {"product_id": self.product.id, "product_uom_qty": 2} + ) + ], + } + ) + ) + bookings = order.resource_booking_ids + bookings.action_cancel() + self.ResourceBookingController._booking_redirection(bookings[0], 1) + self.assertEqual(bookings.mapped("state"), ["pending"] * 2) + self.ResourceBookingController._check_cart(order) + + def test_booking_sale_order(self): + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + {"product_id": self.product.id, "product_uom_qty": 2} + ) + ], + } + ) + order.write({"partner_id": self.public_customer.id}) + order._onchange_partner_id_warning() + + @mute_logger("odoo.models.unlink") + def test_partner_id_change(self): + """Abandoned cart expires bookings.""" + website = self.env["website"].create( + {"name": "Test Website", "partner_id": self.partner.id} + ) + # 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": [ + Command.create( + {"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: + booking.start = dt + wizard = self.env["sale.order.booking.confirm"].create( + { + "order_id": order.id, + } + ) + self.assertEqual(order.resource_booking_count, 2) + wizard = wizard.with_context(trigger_booking_email=True) + wizard.action_invite() + order._onchange_partner_id_warning() + order.action_confirm() 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..7823437da5 --- /dev/null +++ b/website_sale_resource_booking/views/product_template_view.xml @@ -0,0 +1,23 @@ + + + + + Display booking expiration + product.template + + + + + + + +