From 5d8293f73ee7ccb10d60b6ffd9b9259d499dbd47 Mon Sep 17 00:00:00 2001 From: maud-buchwalter Date: Tue, 18 Aug 2026 17:52:11 +0200 Subject: [PATCH] [ADD] user friendly error handling for restricted qty on ecommerce app --- sale_restricted_qty_website_sale/__init__.py | 1 + .../__manifest__.py | 35 ++++++++ sale_restricted_qty_website_sale/i18n/fr.po | 80 +++++++++++++++++ .../models/__init__.py | 2 + .../models/product_restricted_qty_mixin.py | 59 ++++++++++++ .../models/sale_order_line.py | 62 +++++++++++++ .../static/src/js/add_to_cart.js | 45 ++++++++++ .../tests/__init__.py | 1 + .../tests/test_restricted_qty_website_sale.py | 89 +++++++++++++++++++ .../restricted_qty_website_sale_view.xml | 19 ++++ 10 files changed, 393 insertions(+) create mode 100644 sale_restricted_qty_website_sale/__init__.py create mode 100644 sale_restricted_qty_website_sale/__manifest__.py create mode 100644 sale_restricted_qty_website_sale/i18n/fr.po create mode 100644 sale_restricted_qty_website_sale/models/__init__.py create mode 100644 sale_restricted_qty_website_sale/models/product_restricted_qty_mixin.py create mode 100644 sale_restricted_qty_website_sale/models/sale_order_line.py create mode 100644 sale_restricted_qty_website_sale/static/src/js/add_to_cart.js create mode 100644 sale_restricted_qty_website_sale/tests/__init__.py create mode 100644 sale_restricted_qty_website_sale/tests/test_restricted_qty_website_sale.py create mode 100644 sale_restricted_qty_website_sale/views/restricted_qty_website_sale_view.xml diff --git a/sale_restricted_qty_website_sale/__init__.py b/sale_restricted_qty_website_sale/__init__.py new file mode 100644 index 0000000..9a7e03e --- /dev/null +++ b/sale_restricted_qty_website_sale/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/sale_restricted_qty_website_sale/__manifest__.py b/sale_restricted_qty_website_sale/__manifest__.py new file mode 100644 index 0000000..4e5bcd1 --- /dev/null +++ b/sale_restricted_qty_website_sale/__manifest__.py @@ -0,0 +1,35 @@ +# Copyright 2026 Elabore () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "sale_restricted_qty_website_sale", + "version": "16.0.1.0.0", + "author": "Elabore", + "website": "https://elabore.coop", + "maintainer": "Elabore", + "license": "AGPL-3", + "category": "Sales", + "summary": "Improve UX handling on e-commerce product page", + # any module necessary for this one to work correctly + "depends": [ + "sale_restricted_qty","website_sale", + ], + "qweb": [], + "external_dependencies": { + "python": [], + }, + # always loaded + "data": [ + "views/restricted_qty_website_sale_view.xml" + ], + "assets": { + "web.assets_frontend": [ + "sale_restricted_qty_website_sale/static/src/js/add_to_cart.js", + ], + }, + "installable": True, + # Install this module automatically if all dependency have been previously + # and independently installed. Used for synergetic or glue modules. + "auto_install": True, + "application": False, +} \ No newline at end of file diff --git a/sale_restricted_qty_website_sale/i18n/fr.po b/sale_restricted_qty_website_sale/i18n/fr.po new file mode 100644 index 0000000..61b5aa1 --- /dev/null +++ b/sale_restricted_qty_website_sale/i18n/fr.po @@ -0,0 +1,80 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * elabore_website_sale_restricted_qty +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-08-18 00:00+0000\n" +"Last-Translator: Élabore \n" +"Language-Team: \n" +"Language: fr\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" + +#. module: elabore_website_sale_restricted_qty +#. odoo-python +#: code:addons/elabore_website_sale_restricted_qty/models/sale_order_line.py:0 +#, python-format +msgid "" +"The selected quantity is not available for sale:\n" +"\n" +"%s\n" +"\n" +"Please adjust the quantity." +msgstr "" +"La quantité choisie n'est pas disponible à la vente :\n" +"\n" +"%s\n" +"\n" +"Merci d'ajuster la quantité." + +#. module: elabore_website_sale_restricted_qty +#. odoo-python +#: code:addons/elabore_website_sale_restricted_qty/models/sale_order_line.py:0 +#, python-format +msgid "%(product)s: minimum order quantity is %(qty)s." +msgstr "%(product)s : quantité minimum de commande %(qty)s." + +#. module: elabore_website_sale_restricted_qty +#. odoo-python +#: code:addons/elabore_website_sale_restricted_qty/models/sale_order_line.py:0 +#, python-format +msgid "%(product)s: maximum order quantity is %(qty)s." +msgstr "%(product)s : quantité maximum de commande %(qty)s." + +#. module: elabore_website_sale_restricted_qty +#. odoo-python +#: code:addons/elabore_website_sale_restricted_qty/models/sale_order_line.py:0 +#, python-format +msgid "%(product)s: must be ordered in multiples of %(qty)s." +msgstr "%(product)s : à commander par multiples de %(qty)s." + +#. module: elabore_website_sale_restricted_qty +#. odoo-python +#: code:addons/elabore_website_sale_restricted_qty/models/product_restricted_qty_mixin.py:0 +#, python-format +msgid "minimum order: %(qty)s %(uom)s" +msgstr "commande minimum : %(qty)s %(uom)s" + +#. module: elabore_website_sale_restricted_qty +#. odoo-python +#: code:addons/elabore_website_sale_restricted_qty/models/product_restricted_qty_mixin.py:0 +#, python-format +msgid "in multiples of %(qty)s %(uom)s" +msgstr "par multiples de %(qty)s %(uom)s" + +#. module: elabore_website_sale_restricted_qty +#. odoo-python +#: code:addons/elabore_website_sale_restricted_qty/models/product_restricted_qty_mixin.py:0 +#, python-format +msgid "maximum: %(qty)s %(uom)s" +msgstr "maximum : %(qty)s %(uom)s" + +#. module: elabore_website_sale_restricted_qty +#: model:ir.ui.view,name:elabore_website_sale_restricted_qty.product_restricted_qty_info +msgid "Restricted Quantity Information" +msgstr "Information sur les quantités restreintes" \ No newline at end of file diff --git a/sale_restricted_qty_website_sale/models/__init__.py b/sale_restricted_qty_website_sale/models/__init__.py new file mode 100644 index 0000000..a246591 --- /dev/null +++ b/sale_restricted_qty_website_sale/models/__init__.py @@ -0,0 +1,2 @@ +from . import product_restricted_qty_mixin +from . import sale_order_line diff --git a/sale_restricted_qty_website_sale/models/product_restricted_qty_mixin.py b/sale_restricted_qty_website_sale/models/product_restricted_qty_mixin.py new file mode 100644 index 0000000..0a563aa --- /dev/null +++ b/sale_restricted_qty_website_sale/models/product_restricted_qty_mixin.py @@ -0,0 +1,59 @@ +# Copyright 2026 Élabore (https://elabore.coop) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, models +from odoo.tools import float_is_zero +from odoo.tools.misc import formatLang + + +class ProductRestrictedQtyMixin(models.AbstractModel): + """Extending the OCA abstract mixin makes these helpers available at once + on product.product, product.template and product.category.""" + + _inherit = "product.restricted.qty.mixin" + + def _format_restricted_qty(self, qty): + """Short form: 6 rather than 6.0, and a locale-aware separator + for decimals.""" + if float_is_zero(qty - int(qty), precision_digits=3): + return str(int(qty)) + return formatLang(self.env, qty) + + def _get_restricted_qty_hint(self): + """Sentence shown to the customer. Empty when nothing restricts him. + + OCA semantics: `force_sale_min_qty` / `force_sale_max_qty` set to Yes + make the bound purely indicative -- the constraint does not block, so + we announce nothing rather than promising a rule that does not exist. + """ + self.ensure_one() + uom = self.uom_id.name if "uom_id" in self._fields else "" + parts = [] + if self.sale_min_qty and not self.force_sale_min_qty: + parts.append( + _( + "minimum order: %(qty)s %(uom)s", + qty=self._format_restricted_qty(self.sale_min_qty), + uom=uom, + ) + ) + if self.sale_multiple_qty: + parts.append( + _( + "in multiples of %(qty)s %(uom)s", + qty=self._format_restricted_qty(self.sale_multiple_qty), + uom=uom, + ) + ) + if self.sale_max_qty and not self.force_sale_max_qty: + parts.append( + _( + "maximum: %(qty)s %(uom)s", + qty=self._format_restricted_qty(self.sale_max_qty), + uom=uom, + ) + ) + if not parts: + return "" + hint = " · ".join(parts) + return hint[0].upper() + hint[1:] \ No newline at end of file diff --git a/sale_restricted_qty_website_sale/models/sale_order_line.py b/sale_restricted_qty_website_sale/models/sale_order_line.py new file mode 100644 index 0000000..d023ca0 --- /dev/null +++ b/sale_restricted_qty_website_sale/models/sale_order_line.py @@ -0,0 +1,62 @@ +from odoo import _, api, models +from odoo.exceptions import ValidationError + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + @api.constrains( + "product_uom_qty", "sale_min_qty", "sale_max_qty", "sale_multiple_qty" + ) + def check_constraint_restricted_qty(self): + """Rewrite the error message raised by `sale_restricted_qty`. + + The upstream message is written for a salesperson: it dumps the + offending products and explains how to tick "force min quantity" on + the product form. Unreadable for an eCommerce customer. + + Detection is left to the OCA module -- the `is_qty_*` fields stay the + single source of truth. Only the wording is replaced, so an upstream + change to the rules is followed without breaking anything. + """ + try: + return super().check_constraint_restricted_qty() + except ValidationError: + raise ValidationError(self._get_restricted_qty_error_message()) from None + + def _get_restricted_qty_error_message(self): + """Customer-facing message, one line per unmet restriction.""" + details = [] + for line in self: + product = line.product_id + if not product.force_sale_min_qty and line.is_qty_less_min_qty: + details.append( + _( + "%(product)s: minimum order quantity is %(qty)s.", + product=product.display_name, + qty=product._format_restricted_qty(line.sale_min_qty), + ) + ) + if not product.force_sale_max_qty and line.is_qty_bigger_max_qty: + details.append( + _( + "%(product)s: maximum order quantity is %(qty)s.", + product=product.display_name, + qty=product._format_restricted_qty(line.sale_max_qty), + ) + ) + if line.is_qty_not_multiple_qty: + details.append( + _( + "%(product)s: must be ordered in multiples of %(qty)s.", + product=product.display_name, + qty=product._format_restricted_qty(line.sale_multiple_qty), + ) + ) + + return _( + "The selected quantity is not available for sale:\n\n" + "%s\n\n" + "Please adjust the quantity.", + "\n".join(details), + ) \ No newline at end of file diff --git a/sale_restricted_qty_website_sale/static/src/js/add_to_cart.js b/sale_restricted_qty_website_sale/static/src/js/add_to_cart.js new file mode 100644 index 0000000..dc2ba8a --- /dev/null +++ b/sale_restricted_qty_website_sale/static/src/js/add_to_cart.js @@ -0,0 +1,45 @@ +odoo.define("sale_restricted_qty_website_sale.add_to_cart", function (require) { + "use strict"; + + const publicWidget = require("web.public.widget"); + const wSaleUtils = require("website_sale.utils"); + const WebsiteSale = require("website_sale.website_sale").WebsiteSale; + + WebsiteSale.include({ + /** + * + * @override + */ + _addToCartInPage: function () { + return this._super.apply(this, arguments).catch((error) => { + const message = this._getRestrictedQtyMessage(error); + if (!message) { + // Not a quantity restriction: let the real error surface. + return Promise.reject(error); + } + if (error && error.event && error.event.preventDefault) { + // Legacy convention: tells the crash manager the error is + // handled, so it does not open its own dialog on top. + error.event.preventDefault(); + } + wSaleUtils.showWarning(message); + }); + }, + + /** + * @private + * @param {Object} error rejection from the legacy RPC + * @returns {string|false} the message, or false to re-throw + */ + _getRestrictedQtyMessage: function (error) { + const data = + (error && error.message && error.message.data) || + (error && error.data); + if (!data || data.name !== "odoo.exceptions.ValidationError") { + return false; + } + return data.message; + }, + }); + +}); \ No newline at end of file diff --git a/sale_restricted_qty_website_sale/tests/__init__.py b/sale_restricted_qty_website_sale/tests/__init__.py new file mode 100644 index 0000000..39a1822 --- /dev/null +++ b/sale_restricted_qty_website_sale/tests/__init__.py @@ -0,0 +1 @@ +from . import test_restricted_qty_website_sale \ No newline at end of file diff --git a/sale_restricted_qty_website_sale/tests/test_restricted_qty_website_sale.py b/sale_restricted_qty_website_sale/tests/test_restricted_qty_website_sale.py new file mode 100644 index 0000000..fc29faa --- /dev/null +++ b/sale_restricted_qty_website_sale/tests/test_restricted_qty_website_sale.py @@ -0,0 +1,89 @@ +from lxml import html + +from odoo.exceptions import ValidationError +from odoo.tests import HttpCase, tagged + +from odoo.addons.http_routing.models.ir_http import slug + + +@tagged("post_install", "-at_install") +class TestRestrictedQtyInfo(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "eCommerce customer"}) + cls.product_min = cls._create_product( + "Product min 6", manual_sale_min_qty=6.0 + ) + cls.product_free = cls._create_product("Unrestricted product") + # force = Yes -> purely indicative bound, nothing to tell the customer + cls.product_indicative = cls._create_product( + "Product min 6 indicative", + manual_sale_min_qty=6.0, + manual_force_sale_min_qty="force", + ) + + @classmethod + def _create_product(cls, name, **restrictions): + values = { + "name": name, + "type": "consu", + "list_price": 10.0, + "sale_ok": True, + "is_published": True, + } + values.update(restrictions) + return cls.env["product.template"].create(values).product_variant_id + + # ------------------------------------------------------------------ + # Error message + # ------------------------------------------------------------------ + + def _create_line(self, product, qty): + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + self.env["sale.order.line"].create( + { + "order_id": order.id, + "product_id": product.id, + "product_uom_qty": qty, + } + ) + # @api.constrains are evaluated on flush, not on create(). + self.env.flush_all() + return order + + def test_error_message_is_customer_facing(self): + with self.assertRaises(ValidationError) as error: + self._create_line(self.product_min, 1) + + message = str(error.exception) + self.assertIn("Product min 6", message) + self.assertIn("6", message) + self.assertNotIn("force min", message, "Upstream jargon still present") + + def test_valid_quantity_raises_nothing(self): + self._create_line(self.product_min, 6) + + def test_indicative_minimum_still_does_not_block(self): + self._create_line(self.product_indicative, 1) + + # ------------------------------------------------------------------ + # Product page display + # ------------------------------------------------------------------ + + def _hint_on_page(self, product): + response = self.url_open("/shop/%s" % slug(product.product_tmpl_id)) + self.assertEqual(response.status_code, 200) + tree = html.fromstring(response.content) + return tree.xpath("//div[contains(@class, 'o_wsale_qty_restriction')]") + + def test_hint_is_displayed(self): + hint = self._hint_on_page(self.product_min) + self.assertEqual(len(hint), 1) + self.assertIn("6", hint[0].text_content()) + + def test_no_hint_without_restriction(self): + self.assertFalse(self._hint_on_page(self.product_free)) + + def test_no_hint_for_an_indicative_minimum(self): + self.assertFalse(self._hint_on_page(self.product_indicative)) \ No newline at end of file diff --git a/sale_restricted_qty_website_sale/views/restricted_qty_website_sale_view.xml b/sale_restricted_qty_website_sale/views/restricted_qty_website_sale_view.xml new file mode 100644 index 0000000..b431801 --- /dev/null +++ b/sale_restricted_qty_website_sale/views/restricted_qty_website_sale_view.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file