diff --git a/addons/event/tests/test_event_slot.py b/addons/event/tests/test_event_slot.py index 766009472b2ee..d86982c025ec1 100644 --- a/addons/event/tests/test_event_slot.py +++ b/addons/event/tests/test_event_slot.py @@ -203,17 +203,19 @@ def test_seats_slots_notickets(self): new.with_user(self.user_eventmanager).unlink() # check ``_verify_seats_availability`` itself - for check_input, should_crash in [ + for check_input, should_crash, problematic_name in [ # ok for event max seats for both slots - (((first_slot, False, 2), (second_slot, False, 4)), False), + (((first_slot, False, 2), (second_slot, False, 4)), False, None), # not enough seats on first slot - (((first_slot, False, 3),), True), + (((first_slot, False, 3),), True, first_slot.display_name), # not enough seats on second slot - (((second_slot, False, 5),), True), + (((second_slot, False, 5),), True, second_slot.display_name), ]: - with self.subTest(check_input=check_input, should_crash=should_crash): + with self.subTest(check_input=check_input, should_crash=should_crash, problematic_name=problematic_name): if should_crash: - with self.assertRaises(exceptions.ValidationError): + # Error message should reference the slot name. + # See `sale.order._is_cart_ready_for_checkout` for a why. + with self.assertRaisesRegex(exceptions.ValidationError, problematic_name): test_event._verify_seats_availability(check_input) else: test_event._verify_seats_availability(check_input) diff --git a/addons/payment_stripe/static/src/interactions/express_checkout.js b/addons/payment_stripe/static/src/interactions/express_checkout.js index 3439fc278f52c..736efffcf70af 100644 --- a/addons/payment_stripe/static/src/interactions/express_checkout.js +++ b/addons/payment_stripe/static/src/interactions/express_checkout.js @@ -1,11 +1,12 @@ /* global Stripe */ -import { patch } from '@web/core/utils/patch'; +import { ExpressCheckout } from '@payment/interactions/express_checkout'; +import { StripeOptions } from '@payment_stripe/js/stripe_options'; +import { ConfirmationDialog } from '@web/core/confirmation_dialog/confirmation_dialog'; import { _t } from '@web/core/l10n/translation'; import { rpc } from '@web/core/network/rpc'; +import { patch } from '@web/core/utils/patch'; import { redirect } from '@web/core/utils/urls'; -import { ExpressCheckout } from '@payment/interactions/express_checkout'; -import { StripeOptions } from '@payment_stripe/js/stripe_options'; patch(ExpressCheckout.prototype, { /** @@ -136,7 +137,15 @@ patch(ExpressCheckout.prototype, { const { client_secret } = await this.waitFor(rpc( this.paymentContext['transactionRoute'], this._prepareTransactionRouteParams(providerData.providerId), - )); + ).catch(error => { + ev.complete('fail'); + this.call('dialog', 'add', ConfirmationDialog, { + title: _t("Payment failed"), + body: error.data.message || "", + }); + return {}; + })); + if (!client_secret) return; // Confirm the PaymentIntent without handling eventual next actions (e.g. 3DS). const { paymentIntent, error: confirmError } = await this.waitFor( stripeJS.confirmCardPayment( diff --git a/addons/website_event_sale/controllers/__init__.py b/addons/website_event_sale/controllers/__init__.py index e3b8c5e0e7634..cde2a0c45f3b6 100644 --- a/addons/website_event_sale/controllers/__init__.py +++ b/addons/website_event_sale/controllers/__init__.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- from . import main -from . import payment from . import sale diff --git a/addons/website_event_sale/controllers/payment.py b/addons/website_event_sale/controllers/payment.py deleted file mode 100644 index e4600dc2af65d..0000000000000 --- a/addons/website_event_sale/controllers/payment.py +++ /dev/null @@ -1,32 +0,0 @@ -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo.http import request - -from odoo.addons.website_sale.controllers.payment import PaymentPortal - - -class PaymentPortalOnsite(PaymentPortal): - - def _validate_transaction_for_order(self, transaction, sale_order): - """ - Throws a ValidationError if the user tries to pay for a ticket which isn't available - """ - super()._validate_transaction_for_order(transaction, sale_order) - registration_domain = [ - ('sale_order_id', '=', sale_order.id), - ('event_ticket_id', '!=', False), - ('state', '!=', 'cancel'), - ] - registrations_per_event = request.env['event.registration'].sudo()._read_group( - registration_domain, - ['event_id'], ['id:recordset'] - ) - for event, registrations in registrations_per_event: - count_per_slot_ticket = request.env['event.registration'].sudo()._read_group( - [('id', 'in', registrations.ids)], - ['event_slot_id', 'event_ticket_id'], ['__count'] - ) - event._verify_seats_availability([ - (slot, ticket, count) - for slot, ticket, count in count_per_slot_ticket - ]) diff --git a/addons/website_event_sale/models/sale_order.py b/addons/website_event_sale/models/sale_order.py index b1e892a9bf2a2..f12c4a01f6405 100644 --- a/addons/website_event_sale/models/sale_order.py +++ b/addons/website_event_sale/models/sale_order.py @@ -1,7 +1,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import _, models -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError class SaleOrder(models.Model): @@ -125,3 +125,36 @@ def _filter_can_send_abandoned_cart_mail(self): return super()._filter_can_send_abandoned_cart_mail().filtered( lambda so: all(ticket.sale_available for ticket in so.order_line.event_ticket_id), ) + + def _is_cart_ready_for_checkout(self): + """Override of `website_sale` to check if the user is trying to order a ticket that is no + longer available.""" + ready = super()._is_cart_ready_for_checkout() + if not (ready and self.order_line.event_id): + return ready + + # Check that there are enough seats available per ticket. + registration_domain = [ + ('sale_order_id', '=', self.id), + ('event_ticket_id', '!=', False), + ('state', '!=', 'cancel'), + ] + registrations_per_event = self.env['event.registration'].sudo()._read_group( + registration_domain, ['event_id'], ['id:recordset'], + ) + errors = [] + for event, registrations in registrations_per_event: + count_per_slot_ticket = self.env['event.registration'].sudo()._read_group( + [('id', 'in', registrations.ids)], + ['event_slot_id', 'event_ticket_id'], + ['__count'], + ) + try: + event._verify_seats_availability(count_per_slot_ticket) + except ValidationError as e: + errors.append(str(e)) + + if errors: + # The error messages already inform the customer about which ticket is unavailable. + self.shop_warning = "\n".join(errors) + return not errors diff --git a/addons/website_event_sale/tests/__init__.py b/addons/website_event_sale/tests/__init__.py index 1301c3c4319b6..84c918197a785 100644 --- a/addons/website_event_sale/tests/__init__.py +++ b/addons/website_event_sale/tests/__init__.py @@ -1,6 +1,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import common +from . import test_event_checkout from . import test_frontend_buy_tickets from . import test_website_event_sale_cart from . import test_website_event_sale_pricelist diff --git a/addons/website_event_sale/tests/test_event_checkout.py b/addons/website_event_sale/tests/test_event_checkout.py new file mode 100644 index 0000000000000..f4562bcc19035 --- /dev/null +++ b/addons/website_event_sale/tests/test_event_checkout.py @@ -0,0 +1,55 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.fields import Command +from odoo.tests import HttpCase, tagged + +from odoo.addons.website_event_sale.tests.common import TestWebsiteEventSaleCommon +from odoo.addons.website_sale.controllers.main import WebsiteSale as CheckoutController +from odoo.addons.website_sale.tests.common import MockRequest + + +@tagged('post_install', '-at_install') +class TestEventCheckout(TestWebsiteEventSaleCommon, HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.CheckoutController = CheckoutController() + + def test_checkout_impossible_if_tickets_are_expired(self): + self.ticket.write({ + 'seats_max': 1, + 'seats_limited': True, + }) + + so1 = self._create_so(order_line=[Command.create({ + 'product_id': self.ticket.product_id.id, + 'event_id': self.event.id, + 'event_ticket_id': self.ticket.id, + })]) + so2 = self._create_so(order_line=[Command.create({ + 'product_id': self.ticket.product_id.id, + 'event_id': self.event.id, + 'event_ticket_id': self.ticket.id, + })]) + self.env['event.registration'].create([ + { + 'state': 'draft', + 'sale_order_id': so1.id, + 'partner_id': so1.partner_id.id, + 'event_id': self.event.id, + 'event_ticket_id': self.ticket.id, + }, + ]) + + so2.action_confirm() + self.assertEqual(self.event.seats_available, 0) + self.assertEqual(self.event.seats_taken, 1) + + website = self.website.with_user(self.public_user) + with MockRequest(website.env, website=website, path='/shop/checkout', sale_order_id=so1.id): + response = self.CheckoutController.shop_checkout() + + # SO2 was too quick and registered before SO1 => redirected to change tickets + self.assertEqual(response.status_code, 303, 'SEE OTHER') + self.assertURLEqual(response.location, '/shop/cart') diff --git a/addons/website_sale/controllers/cart.py b/addons/website_sale/controllers/cart.py index d1480385dcf04..65842dc57378e 100644 --- a/addons/website_sale/controllers/cart.py +++ b/addons/website_sale/controllers/cart.py @@ -52,6 +52,8 @@ def cart(self, id=None, access_token=None, revive_method='', **post): values.update({ 'website_sale_order': order_sudo, + 'order': order_sudo, + 'shop_warnings': order_sudo._pop_shop_warnings(), 'date': fields.Date.today(), 'suggested_products': [], }) @@ -223,31 +225,7 @@ def add_to_cart( ) def quick_add(self, product_template_id, product_id, quantity=1.0, **kwargs): values = self.add_to_cart(product_template_id, product_id, quantity=quantity, **kwargs) - - IrUiView = request.env['ir.ui.view'] - order_sudo = request.cart - values['website_sale.cart_lines'] = IrUiView._render_template( - 'website_sale.cart_lines', { - 'website_sale_order': order_sudo, - 'date': fields.Date.today(), - 'suggested_products': order_sudo._cart_accessories(), - } - ) - values['website_sale.shorter_cart_summary'] = IrUiView._render_template( - 'website_sale.shorter_cart_summary', { - 'website_sale_order': order_sudo, - 'show_shorter_cart_summary': True, - **self._get_express_shop_payment_values(order_sudo), - **request.website._get_checkout_step_values(), - } - ) - values['website_sale.quick_reorder_history'] = IrUiView._render_template( - 'website_sale.quick_reorder_history', { - 'website_sale_order': order_sudo, - **self._prepare_order_history(), - } - ) - values['cart_ready'] = order_sudo._is_cart_ready() + values.update(self._get_cart_update_values(request.cart)) return values def _get_express_shop_payment_values(self, order, **kwargs): @@ -298,7 +276,6 @@ def update_cart(self, line_id, quantity, product_id=None, **kwargs): """ order_sudo = request.cart quantity = int(quantity) # Do not allow float values in ecommerce by default - IrUiView = request.env['ir.ui.view'] # This method must be only called from the cart page BUT in some advanced logic # eg. website_sale_loyalty, a cart line could be a temporary record without id. @@ -309,34 +286,43 @@ def update_cart(self, line_id, quantity, product_id=None, **kwargs): )[:1].id values = order_sudo._cart_update_line_quantity(line_id, quantity, **kwargs) + values.update(self._get_cart_update_values(order_sudo)) + return values + + def _get_cart_update_values(self, order_sudo): + """Construct the values needed to update the UI after a cart update. + + :param sale.order order_sudo: The current cart order. + :rtype: dict + """ + IrUiView = request.env['ir.ui.view'] - values['cart_quantity'] = order_sudo.cart_quantity - values['cart_ready'] = order_sudo._is_cart_ready() - values['amount'] = order_sudo.amount_total - values['minor_amount'] = ( - order_sudo and payment_utils.to_minor_currency_units( + return { + 'cart_quantity': order_sudo.cart_quantity, + 'cart_ready': order_sudo._is_cart_ready_for_checkout(), + 'amount': order_sudo.amount_total, + 'minor_amount': payment_utils.to_minor_currency_units( order_sudo.amount_total, order_sudo.currency_id - ) - ) or 0.0 - values['website_sale.cart_lines'] = IrUiView._render_template( - 'website_sale.cart_lines', { - 'website_sale_order': order_sudo, - 'date': fields.Date.today(), - 'suggested_products': order_sudo._cart_accessories() - } - ) - values['website_sale.total'] = IrUiView._render_template( - 'website_sale.total', { - 'website_sale_order': order_sudo, - } - ) - values['website_sale.quick_reorder_history'] = IrUiView._render_template( - 'website_sale.quick_reorder_history', { - 'website_sale_order': order_sudo, - **self._prepare_order_history(), - } - ) - return values + ), + 'website_sale.cart_lines': IrUiView._render_template( + 'website_sale.cart_lines', { + 'website_sale_order': order_sudo, + 'date': fields.Date.today(), + 'suggested_products': order_sudo._cart_accessories(), + 'order': order_sudo, + 'shop_warnings': order_sudo._pop_shop_warnings(), + } + ), + 'website_sale.total': IrUiView._render_template( + 'website_sale.total', {'website_sale_order': order_sudo} + ), + 'website_sale.quick_reorder_history': IrUiView._render_template( + 'website_sale.quick_reorder_history', { + 'website_sale_order': order_sudo, + **self._prepare_order_history(), + } + ), + } def _prepare_order_history(self): """Prepare the order history of the current user. diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 5c8b1394654bf..35deeaf5a307b 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -984,13 +984,12 @@ def shop_checkout(self, try_skip_step=None, **query_params): :return: The rendered checkout page. :rtype: str """ + if redirection := self._validate_previous_checkout_steps(): + return redirection + try_skip_step = str2bool(try_skip_step or 'false') order_sudo = request.cart request.session['sale_last_order_id'] = order_sudo.id - - if redirection := self._check_cart_and_addresses(order_sudo): - return redirection - checkout_page_values = self._prepare_checkout_page_values(order_sudo, **query_params) can_skip_delivery = True # Delivery is only needed for deliverable products. @@ -1029,6 +1028,7 @@ def _prepare_checkout_page_values(self, order_sudo, **kwargs): return { 'order': order_sudo, 'website_sale_order': order_sudo, # Compatibility with other templates. + 'shop_warnings': order_sudo._pop_shop_warnings(), 'use_delivery_as_billing': ( order_sudo.partner_shipping_id == order_sudo.partner_invoice_id ), @@ -1057,11 +1057,11 @@ def shop_address( :return: The rendered address form. :rtype: str """ - use_delivery_as_billing = str2bool(use_delivery_as_billing or 'false') + if redirection := self._validate_previous_checkout_steps(): + return redirection + use_delivery_as_billing = str2bool(use_delivery_as_billing or 'false') order_sudo = request.cart - if redirection := self._check_cart(order_sudo): - return redirection # Retrieve the partner whose address to update, if any, and its address type. partner_sudo, address_type = self._prepare_address_update( @@ -1161,11 +1161,11 @@ def shop_address_submit( :return: A JSON-encoded feedback, with either the success URL or an error message. :rtype: str """ - order_sudo = request.cart - if redirection := self._check_cart(order_sudo): + if redirection := self._validate_previous_checkout_steps(step_href='/shop/address'): return json.dumps({'redirectUrl': redirection.location}) # Retrieve the partner whose address to update, if any, and its address type. + order_sudo = request.cart partner_sudo, address_type = self._prepare_address_update( order_sudo, partner_id=partner_id and int(partner_id), address_type=address_type ) @@ -1476,21 +1476,21 @@ def extra_info(self, **post): if not extra_step.active: return request.redirect("/shop/payment") - # check that cart is valid - order_sudo = request.cart - redirection = self._check_cart(order_sudo) + redirection = self._validate_previous_checkout_steps() open_editor = request.params.get('open_editor') == 'true' # Do not redirect if it is to edit # (the information is transmitted via the "open_editor" parameter in the url) if not open_editor and redirection: return redirection + order_sudo = request.cart values = { 'website_sale_order': order_sudo, 'post': post, 'escape': lambda x: x.replace("'", r"\'"), 'partner': order_sudo.partner_id.id, 'order': order_sudo, + 'shop_warnings': order_sudo._pop_shop_warnings(), } values.update(request.website._get_checkout_step_values()) @@ -1500,13 +1500,16 @@ def extra_info(self, **post): # === CHECKOUT FLOW - PAYMENT/CONFIRMATION METHODS === # def _get_shop_payment_values(self, order, **kwargs): + shop_warnings = order._pop_shop_warnings() checkout_page_values = { 'sale_order': order, 'website_sale_order': order, - 'errors': self._get_shop_payment_errors(order), + 'shop_warnings': shop_warnings, 'partner': order.partner_invoice_id, 'order': order, + 'only_services': order and order.only_services, 'submit_button_label': _("Pay now"), + **request.website._get_checkout_step_values(), } payment_form_values = { **sale_portal.CustomerPortal._get_payment_values( @@ -1517,25 +1520,11 @@ def _get_shop_payment_values(self, order, **kwargs): 'landing_route': '/shop/payment/validate', 'sale_order_id': order.id, # Allow Stripe to check if tokenization is required. } + if shop_warnings: + payment_form_values.pop('payment_methods_sudo', '') + payment_form_values.pop('tokens_sudo', '') return checkout_page_values | payment_form_values - def _get_shop_payment_errors(self, order): - """ Check that there is no error that should block the payment. - - :param sale.order order: The sales order to pay - :return: A list of errors (error_title, error_message) - :rtype: list[tuple] - """ - errors = [] - - if order._has_deliverable_products() and not order._get_delivery_methods(): - errors.append(( - _("Sorry, we are unable to ship your order."), - _("No shipping method is available for your current order and shipping address." - " Please contact us for more information."), - )) - return errors - @route('/shop/payment', type='http', auth='public', website=True, sitemap=False, list_as_website_content=_lt("Shop Payment")) def shop_payment(self, **post): """ Payment step. This page proposes several payment means based on available @@ -1547,22 +1536,16 @@ def shop_payment(self, **post): did go to a payment.provider website but closed the tab without paying / canceling """ - order_sudo = request.cart - - if redirection := self._check_cart_and_addresses(order_sudo): + if redirection := self._validate_previous_checkout_steps(): return redirection + order_sudo = request.cart order_sudo._recompute_cart() - render_values = self._get_shop_payment_values(order_sudo, **post) - render_values['only_services'] = order_sudo and order_sudo.only_services - - if render_values['errors']: - render_values.pop('payment_methods_sudo', '') - render_values.pop('tokens_sudo', '') - - render_values.update(request.website._get_checkout_step_values()) - return request.render("website_sale.payment", render_values) + return request.render( + 'website_sale.payment', + self._get_shop_payment_values(order_sudo, **post), + ) @route('/shop/payment/validate', type='http', auth="public", website=True, sitemap=False) def shop_payment_validate(self, sale_order_id=None, **post): @@ -1586,18 +1569,20 @@ def shop_payment_validate(self, sale_order_id=None, **post): if not order_sudo: return request.redirect(self._get_shop_path()) - errors = self._get_shop_payment_errors(order_sudo) if order_sudo.state != 'sale' else [] - if errors: - first_error = errors[0] # only display first error - error_msg = f"{first_error[0]}\n{first_error[1]}" - raise ValidationError(error_msg) + if order_sudo.state != 'sale' and not order_sudo._is_cart_ready_to_be_paid(): + raise ValidationError(request.env._( + "An unexpected error occurred, and we could not process your order." + " Please contact us to resolve the issue (Order reference: %(order_ref)s)." + " We apologize for any inconvenience caused.\n\n%(error)s", + order_ref=order_sudo.name, + error=order_sudo.shop_warning, + )) tx_sudo = order_sudo.get_portal_last_transaction() if order_sudo.amount_total and not tx_sudo: return request.redirect(self._get_shop_path()) if not order_sudo.amount_total and not tx_sudo and order_sudo.state != 'sale': - order_sudo._check_cart_is_ready_to_be_paid() # Only confirm the order if it wasn't already confirmed. order_sudo._validate_order() @@ -1632,6 +1617,7 @@ def _prepare_shop_payment_confirmation_values(self, order): return { 'order': order, 'website_sale_order': order, + 'shop_warnings': order._pop_shop_warnings(), 'order_tracking_info': self.order_2_return_dict(order), } @@ -1646,31 +1632,62 @@ def print_saleorder(self, **kwargs): # === CHECK METHODS === # - def _check_cart_and_addresses(self, order_sudo): - """ Check whether the cart and its addresses are valid, and redirect to the appropriate page - if not. + def _validate_previous_checkout_steps(self, step_href=None, order_sudo=None): + """Check that all the checkout steps prior to the current step are valid; otherwise, + redirect to the page where actions are still required. - :param sale.order order_sudo: The cart to check. - :return: None if both the cart and its addresses are valid; otherwise, a redirection to the - appropriate page. + :param str step_href: The current step href. Defaults to `request.httprequest.path`. + :param sale.order order_sudo: The cart to check. Defaults to `request.cart`. + :return: None if the user can be on the current step; otherwise, a redirection. + :rtype: None | http.Response """ - if redirection := self._check_cart(order_sudo): - return redirection + step_href = ( + step_href + or request.env['ir.http'].url_unrewrite(request.httprequest.path, request.website.id) + ) + order_sudo = order_sudo or request.cart - if redirection := self._check_addresses(order_sudo): - return redirection + previous_steps = request.website._get_checkout_step(step_href)._get_previous_checkout_steps( + Domain('website_id', '=', request.website.id), + ) + for prev_step_href in previous_steps.sorted('sequence').mapped('step_href'): + if redirection := self._check_checkout_step(prev_step_href, order_sudo): + return redirection + + def _check_checkout_step(self, step_href, order_sudo): + """Check that the given step is finished and valid; otherwise, redirect to the page where + actions are still required. + + This method is intended to be overridden by other modules if further validation is needed at + a specific step. + + :param str step_href: The checkout step href to check. + :param sale.order order_sudo: The current cart. + :return: None if the given step is valid; otherwise, a redirection to the appropriate page. + :rtype: None | http.Response + """ + match step_href: + case '/shop/cart': + return self._check_shop_cart_step_ready(order_sudo) + case '/shop/address': + return self._check_shop_address_step_ready(order_sudo) + case '/shop/checkout': + return self._check_shop_checkout_step_ready(order_sudo) - def _check_cart(self, order_sudo): - """ Check whether the cart is a valid, and redirect to the appropriate page if not. + def _check_shop_cart_step_ready(self, order_sudo): + """Check whether the `/shop/cart` step is valid, and redirect to the appropriate page + otherwise. - The cart is only valid if: + The `/shop/cart` step is only valid if: - - it exists and is in the draft state; - - it contains products (i.e., order lines); - - either the user is logged in, or public orders are allowed. + - the cart exists and is in the draft state; + - either the user is logged in, or public orders are allowed; + - the cart is ready for checkout (see also `sale.order._is_cart_ready_for_checkout`); + - the cart's addresses are complete and valid. :param sale.order order_sudo: The cart to check. - :return: None if the cart is valid; otherwise, a redirection to the appropriate page. + :return: None if the step is valid; otherwise, a redirection to the appropriate page. + :rtype: None | http.Response """ # Check that the cart exists and is in the draft state. if not order_sudo or order_sudo.state != 'draft': @@ -1678,27 +1695,26 @@ def _check_cart(self, order_sudo): request.session['sale_transaction_id'] = None return request.redirect(self._get_shop_path()) - # Check that the cart is not empty. - if not order_sudo.order_line: - return request.redirect('/shop/cart') - # Check that public orders are allowed. if request.env.user._is_public() and request.website.account_on_checkout == 'mandatory': return request.redirect('/web/login?redirect=/shop/checkout') - def _check_addresses(self, order_sudo): - """ Check whether the cart's addresses are complete and valid. + if not order_sudo._is_cart_ready_for_checkout(): + return request.redirect('/shop/cart') + + def _check_shop_address_step_ready(self, order_sudo): + """Check whether the `/shop/address` step is valid, and redirect to the appropriate page + otherwise. - The addresses are complete and valid if: + The addresses are considered complete and valid if: - at least one address has been added; - the delivery address is complete; - the billing address is complete. - :param sale.order order_sudo: The cart whose addresses to check. - None if the cart is valid; otherwise, a redirection to the appropriate page. - :return: None if the cart's addresses are complete and valid; otherwise, a redirection to - the appropriate page. + :param sale.order order_sudo: The cart whose addresses are to be checked. + :return: None if the step is valid; otherwise, a redirection to the appropriate page. + :rtype: None | http.Response """ # Check that an address has been added. if order_sudo._is_anonymous_cart(): @@ -1724,6 +1740,20 @@ def _check_addresses(self, order_sudo): f'/shop/address?partner_id={invoice_partner_sudo.id}&address_type=billing' ) + def _check_shop_checkout_step_ready(self, order_sudo): + """Check whether the `/shop/checkout` step is valid, and redirect to the appropriate page + otherwise. + + The `/shop/checkout` step is only valid if the cart is ready for payment. See also + `sale.order._is_cart_ready_for_payment`. + + :param sale.order order_sudo: The cart to check. + :return: None if the step is valid; otherwise, a redirection to the appropriate page. + :rtype: None | http.Response + """ + if not order_sudo._is_cart_ready_for_payment(): + return request.redirect('/shop/checkout') + # ------------------------------------------------------ # Edit # ------------------------------------------------------ diff --git a/addons/website_sale/controllers/payment.py b/addons/website_sale/controllers/payment.py index c69891150658c..48fef8da9836c 100644 --- a/addons/website_sale/controllers/payment.py +++ b/addons/website_sale/controllers/payment.py @@ -52,7 +52,12 @@ def shop_payment_transaction(self, order_id, access_token, **kwargs): if order_sudo.state == "cancel": raise ValidationError(_("The order has been cancelled.")) - order_sudo._check_cart_is_ready_to_be_paid() + # Ensure the cart is still valid before proceeding any further. + if not order_sudo._is_cart_ready_to_be_paid(): + raise ValidationError(_( + "Your cart is not ready to be paid, please verify previous steps.\n%s", + order_sudo.shop_warning, + )) self._validate_transaction_kwargs(kwargs) kwargs.update({ diff --git a/addons/website_sale/data/website_checkout_step_data.xml b/addons/website_sale/data/website_checkout_step_data.xml index 37e9f7a4f6dfe..0a746aa3487bc 100644 --- a/addons/website_sale/data/website_checkout_step_data.xml +++ b/addons/website_sale/data/website_checkout_step_data.xml @@ -17,6 +17,13 @@ Back to address + + Address + 125 + /shop/address + + + Extra Info 500 diff --git a/addons/website_sale/models/ir_http.py b/addons/website_sale/models/ir_http.py index 6269e26e94774..3c7d68bb365c7 100644 --- a/addons/website_sale/models/ir_http.py +++ b/addons/website_sale/models/ir_http.py @@ -1,6 +1,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, models +from odoo import api, models, tools +from odoo.fields import Domain from odoo.http import request from odoo.tools import lazy @@ -32,3 +33,15 @@ def _frontend_pre_dispatch(cls): request.cart = lazy(request.website._get_and_cache_current_cart) request.fiscal_position = lazy(request.website._get_and_cache_current_fiscal_position) request.pricelist = lazy(request.website._get_and_cache_current_pricelist) + + @api.model + @tools.ormcache('path', 'website_id') + def url_unrewrite(self, path, website_id): + """Returns the opposite of `url_rewrite`. Note that this method does not support query + params inside the path. + """ + return self.env['website.rewrite'].sudo().search(Domain([ + ('redirect_type', '=', '308'), + ('url_to', '=', path), + ('website_id', 'in', [False, website_id]), + ]), limit=1).url_from or path diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index 3002858da2ce8..0b2e8699f9e47 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -616,7 +616,7 @@ def _check_combo_quantities(self, line) -> bool: combo_lines = line.linked_line_ids available_combo_quantity = min(line.product_uom_qty for line in combo_lines) if available_combo_quantity < line.product_uom_qty: - line._set_shop_warning_stock( + line.shop_warning = line._get_shop_warning_stock( line.product_uom_qty, available_combo_quantity, ) @@ -856,32 +856,85 @@ def _get_lang(self): return res - def _get_shop_warning(self, clear=True): + def _pop_shop_warnings(self): + """Clear and return the warnings associated with the current orders. + + Note: Can be called with an empty recordset. + + :return: A dict mapping the orders or order lines to a warning message, if any. + :rtype: dict[sale.order | sale.order.line, str] + """ + warnings = {} + if not self: + return warnings + self.ensure_one() - warn = self.shop_warning - if clear: - self.shop_warning = '' - return warn + if (warning := self.shop_warning): + warnings[self] = warning + + order_lines_with_warning = self.order_line.filtered('shop_warning') + for line in order_lines_with_warning: + warnings[line] = line.shop_warning - def _is_cart_ready(self): - """ Whether the cart is valid and can be confirmed (and paid for) + self.shop_warning = False + order_lines_with_warning.shop_warning = False + return warnings + + def _is_cart_ready_for_checkout(self): + """Whether the cart is valid and the user can proceed to the checkout step. + + This method is also intended to set the `shop_warning` with any relevant message that can + help the customer complete their cart. + + Note: self.ensure_one() :rtype: bool """ - return bool(self) + self.ensure_one() + if not self.order_line: + self.shop_warning = self.env._("Your cart is empty!") + return False + return True + + def _is_cart_ready_for_payment(self): + """Whether the cart is ready to be confirmed and the user can proceed to the payment step. - def _check_cart_is_ready_to_be_paid(self): - """ Whether the cart is valid and the user can proceed to the payment + By default, the cart must have a delivery method if it contains deliverable products. + + This method is also intended to set the `shop_warning` with any relevant message that can + help the customer complete their cart. + + Note: self.ensure_one() :rtype: bool """ - if not self._is_cart_ready(): - raise ValidationError(_( - "Your cart is not ready to be paid, please verify previous steps." - )) + self.ensure_one() + + if self._has_deliverable_products(): + if not self._get_delivery_methods(): + self.shop_warning = self.env._( + "Sorry, we are unable to ship your order.\n" + "No shipping method is available for your current order and shipping address." + " Please contact us for more information." + ) + return False + if not self.carrier_id: + self.shop_warning = self.env._("No shipping method is selected.") + return False + return True + + def _is_cart_ready_to_be_paid(self): + """Whether the cart is valid and can be paid for. - if not self.only_services and not self.carrier_id: - raise ValidationError(_("No shipping method is selected.")) + This method is also intended to set the `shop_warning` with any relevant message that can + help the customer complete their cart. + + Note: self.ensure_one() + + :rtype: bool + """ + self.ensure_one() + return self._is_cart_ready_for_checkout() and self._is_cart_ready_for_payment() def _recompute_cart(self): """Recompute taxes and prices for the current cart.""" diff --git a/addons/website_sale/models/sale_order_line.py b/addons/website_sale/models/sale_order_line.py index 7395bf0815dfb..12566b321a2ad 100644 --- a/addons/website_sale/models/sale_order_line.py +++ b/addons/website_sale/models/sale_order_line.py @@ -42,13 +42,6 @@ def _get_order_date(self): return fields.Datetime.now() return super()._get_order_date() - def _get_shop_warning(self, clear=True): - self.ensure_one() - warn = self.shop_warning - if clear: - self.shop_warning = '' - return warn - def _get_displayed_unit_price(self): show_tax = self.order_id.website_id.show_line_subtotals_tax_selection tax_display = 'total_excluded' if show_tax == 'tax_excluded' else 'total_included' diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py index a8866782b8d55..d87e426a2c562 100644 --- a/addons/website_sale/models/website.py +++ b/addons/website_sale/models/website.py @@ -934,14 +934,26 @@ def create(self, vals_list): return websites def _create_checkout_steps(self): - generic_steps = self.env['website.checkout.step'].sudo().search([ - ('website_id', '=', False), - ]) + generic_steps = self.env['website.checkout.step'].sudo().search( + [('website_id', '=', False)], order='parent_id DESC', # Create the root-steps first + ) for step in generic_steps: - is_published = True + values = { + 'website_id': self.id, + # "Sub"-steps are not shown explicitly to the user + 'is_published': not step.parent_id, + 'parent_id': ( + self.env['website.checkout.step'].sudo().search(Domain([ + ('website_id', '=', self.id), ('step_href', '=', step.parent_id.step_href), + ]), limit=1).id + if step.parent_id else False + ), + } if step.step_href == '/shop/extra_info': - is_published = self.with_context(website_id=self.id).viewref('website_sale.extra_info').active - step.copy({'website_id': self.id, 'is_published': is_published}) + values['is_published'] = self.with_context( + website_id=self.id, + ).viewref('website_sale.extra_info').active + step.copy(values) def _get_checkout_step(self, href): return self.env['website.checkout.step'].sudo().search([ @@ -949,6 +961,11 @@ def _get_checkout_step(self, href): ('step_href', '=', href), ], limit=1) + def _get_previous_checkout_steps(self, href, limit=None): + return self._get_checkout_step(href)._get_previous_checkout_steps( + self._get_allowed_steps_domain(), limit=limit, + ) + def _get_allowed_steps_domain(self): return [ ('website_id', '=', self.id), @@ -962,20 +979,16 @@ def _get_checkout_steps(self): return steps def _get_checkout_step_values(self): - def rewrite(path): - return self.env['ir.http'].url_rewrite(path)[0] - href = rewrite(request.httprequest.path) - # /shop/address is associated with the delivery step - if href == rewrite('/shop/address'): - href = rewrite('/shop/checkout') - + step_href = request_href = self.env['ir.http'].url_unrewrite( + request.httprequest.path, self.id, + ) allowed_steps_domain = self._get_allowed_steps_domain() - current_step = request.env['website.checkout.step'].sudo() - for step in current_step.search(allowed_steps_domain): - if rewrite(step.step_href) == href: - current_step = step - href = step.step_href - break + + current_step = self._get_checkout_step(step_href) + if current_step.parent_id: + current_step = current_step.parent_id + current_step = current_step.filtered_domain(allowed_steps_domain) + next_step = current_step._get_next_checkout_step(allowed_steps_domain) previous_step = current_step._get_previous_checkout_step(allowed_steps_domain) @@ -984,11 +997,11 @@ def rewrite(path): if next_step.step_href == '/shop/checkout': next_href = '/shop/checkout?try_skip_step=true' # redirect handled by '/shop/address/submit' route when all values are properly filled - if request.httprequest.path == rewrite('/shop/address'): + if request_href == '/shop/address': next_href = False return { - 'current_website_checkout_step_href': href, + 'current_website_checkout_step_href': step_href, 'previous_website_checkout_step': previous_step, 'next_website_checkout_step': next_step, 'next_website_checkout_step_href': next_href, diff --git a/addons/website_sale/models/website_checkout_step.py b/addons/website_sale/models/website_checkout_step.py index 5d24b879b7a1f..170261aa9f4e5 100644 --- a/addons/website_sale/models/website_checkout_step.py +++ b/addons/website_sale/models/website_checkout_step.py @@ -15,6 +15,8 @@ class WebsiteCheckoutStep(models.Model): main_button_label = fields.Char(translate=True) back_button_label = fields.Char(translate=True) website_id = fields.Many2one('website', ondelete='cascade') + # Note: Only a single level of hierarchy is supported. + parent_id = fields.Many2one('website.checkout.step') def _get_next_checkout_step(self, allowed_steps_domain): """ Get the next step in the checkout flow based on the sequence.""" @@ -26,8 +28,12 @@ def _get_next_checkout_step(self, allowed_steps_domain): def _get_previous_checkout_step(self, allowed_steps_domain): """ Get the previous step in the checkout flow based on the sequence.""" + return self._get_previous_checkout_steps(allowed_steps_domain, limit=1) + + def _get_previous_checkout_steps(self, allowed_steps_domain, limit=None): + """ Get the previous steps in the checkout flow based on the sequence.""" previous_step_domain = Domain.AND( [allowed_steps_domain, [('sequence', '<', self.sequence)]] ) - return self.search(previous_step_domain, order='sequence DESC', limit=1) + return self.search(previous_step_domain, order='sequence DESC', limit=limit) diff --git a/addons/website_sale/templates/checkout/cart_templates.xml b/addons/website_sale/templates/checkout/cart_templates.xml index e19e83fd285e1..e0e009da39fc2 100644 --- a/addons/website_sale/templates/checkout/cart_templates.xml +++ b/addons/website_sale/templates/checkout/cart_templates.xml @@ -47,12 +47,11 @@

- -