From 3374c417914dcdb26b6a6642d7313deec5769547 Mon Sep 17 00:00:00 2001 From: Unai Beristain Date: Wed, 21 Aug 2024 12:21:56 +0200 Subject: [PATCH] [ADD] website_sale_float_cart_qty: Add float qty in cart --- .../odoo/addons/website_sale_float_cart_qty | 1 + setup/website_sale_float_cart_qty/setup.py | 6 + website_sale_float_cart_qty/README.rst | 54 ++++++ website_sale_float_cart_qty/__init__.py | 1 + website_sale_float_cart_qty/__manifest__.py | 18 ++ .../models/__init__.py | 1 + .../models/sale_order.py | 125 ++++++++++++++ .../static/src/js/float_qty.js | 86 +++++++++ website_sale_float_cart_qty/tests/__init__.py | 1 + .../tests/test_cart_float_qty.py | 163 ++++++++++++++++++ 10 files changed, 456 insertions(+) create mode 120000 setup/website_sale_float_cart_qty/odoo/addons/website_sale_float_cart_qty create mode 100644 setup/website_sale_float_cart_qty/setup.py create mode 100644 website_sale_float_cart_qty/README.rst create mode 100644 website_sale_float_cart_qty/__init__.py create mode 100644 website_sale_float_cart_qty/__manifest__.py create mode 100644 website_sale_float_cart_qty/models/__init__.py create mode 100644 website_sale_float_cart_qty/models/sale_order.py create mode 100644 website_sale_float_cart_qty/static/src/js/float_qty.js create mode 100644 website_sale_float_cart_qty/tests/__init__.py create mode 100644 website_sale_float_cart_qty/tests/test_cart_float_qty.py diff --git a/setup/website_sale_float_cart_qty/odoo/addons/website_sale_float_cart_qty b/setup/website_sale_float_cart_qty/odoo/addons/website_sale_float_cart_qty new file mode 120000 index 0000000000..1522a757a5 --- /dev/null +++ b/setup/website_sale_float_cart_qty/odoo/addons/website_sale_float_cart_qty @@ -0,0 +1 @@ +../../../../website_sale_float_cart_qty \ No newline at end of file diff --git a/setup/website_sale_float_cart_qty/setup.py b/setup/website_sale_float_cart_qty/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/website_sale_float_cart_qty/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/website_sale_float_cart_qty/README.rst b/website_sale_float_cart_qty/README.rst new file mode 100644 index 0000000000..cf5c9736da --- /dev/null +++ b/website_sale_float_cart_qty/README.rst @@ -0,0 +1,54 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +================================ +Website Sale Float Cart Quantity +================================ + +Overview +-------- + +This module extends the functionality of the `website_sale` module in Odoo by allowing float quantities in the shopping cart instead of integer quantities. + +Features +-------- + +- Enables users to add fractional quantities of products to the shopping cart. +- Overrides the `_changeCartQuantity` function from `website_sale` to handle float quantities. +- Uses `parseFloat` instead of `parseInt` for value conversion on the client-side. + +Usage +----- + +- Users can add decimal quantities of products to the shopping cart from the online store. + +- Quantities are dynamically updated in the user interface after each quantity change. + +Development +----------- + +The `website_sale_float_cart_qty` module uses JavaScript to extend the functionality of Odoo's `website_sale` module. The `website_sale_float_cart_qty.js` file overrides the `_changeCartQuantity` function to handle float quantities and perform server communication via RPC. + +Contributions +------------- + +Contributions are welcome! If you want to contribute to the development of this module, feel free to submit a pull request or report issues on the official repository. + +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 smash it by providing detailed and welcomed feedback. + +Credits +======= + +Contributors +------------ +* Ana Juaristi +* Unai Beristain + +Do not contact contributors directly about support or help with technical issues. diff --git a/website_sale_float_cart_qty/__init__.py b/website_sale_float_cart_qty/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/website_sale_float_cart_qty/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/website_sale_float_cart_qty/__manifest__.py b/website_sale_float_cart_qty/__manifest__.py new file mode 100644 index 0000000000..4b935ab7a1 --- /dev/null +++ b/website_sale_float_cart_qty/__manifest__.py @@ -0,0 +1,18 @@ +{ + "name": "Website Sale Float Cart Quantity", + "summary": "Allow float quantities in cart for website sale module", + "version": "16.0.1.0.0", + "category": "Website", + "license": "LGPL-3", + "website": "https://github.com/OCA/e-commerce", + "author": "AvanzOSC", + "depends": [ + "website_sale", + ], + "assets": { + "web.assets_frontend": [ + "website_sale_float_cart_qty/static/src/js/float_qty.js", + ], + }, + "installable": True, +} diff --git a/website_sale_float_cart_qty/models/__init__.py b/website_sale_float_cart_qty/models/__init__.py new file mode 100644 index 0000000000..6aacb75313 --- /dev/null +++ b/website_sale_float_cart_qty/models/__init__.py @@ -0,0 +1 @@ +from . import sale_order diff --git a/website_sale_float_cart_qty/models/sale_order.py b/website_sale_float_cart_qty/models/sale_order.py new file mode 100644 index 0000000000..f8bc80637f --- /dev/null +++ b/website_sale_float_cart_qty/models/sale_order.py @@ -0,0 +1,125 @@ +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class SaleOrderInherit(models.Model): + _inherit = "sale.order" + + cart_quantity = fields.Float( + compute="_compute_cart_info", + ) + + def _cart_update(self, product_id, line_id=None, add_qty=0, set_qty=0, **kwargs): + if ( + (isinstance(add_qty, int) or isinstance(set_qty, int)) + and add_qty is not False + and set_qty is not False + ): + return super()._cart_update(product_id, line_id, add_qty, set_qty, **kwargs) + else: + # Add or set product quantity, add_qty can be negative + self.ensure_one() + self = self.with_company(self.company_id) + + if self.state != "draft": + self.env["request"].session.pop("sale_order_id", None) + self.env["request"].session.pop("website_sale_cart_quantity", None) + raise UserError( + _( + "It is forbidden to modify a sales order which is not in draft status." + ) + ) + + product = self.env["product.product"].browse(product_id).exists() + if add_qty and (not product or not product._is_add_to_cart_allowed()): + raise UserError( + _( + "The given product does not exist therefore it cannot be added to cart." + ) + ) + + if line_id is not False: + order_line = self._cart_find_product_line( + product_id, line_id, **kwargs + )[:1] + else: + order_line = self.env["sale.order.line"] + + quantity = 0 + if set_qty: + quantity = set_qty + elif add_qty: + if order_line: + quantity = order_line.product_uom_qty + add_qty + else: + quantity = add_qty + + warning = "" + + if quantity > 0: + quantity, warning = self._verify_updated_quantity( + order_line, + product_id, + quantity, + **kwargs, + ) + + # Round it to avoid infinite 0 with a one after it + quantity = round(quantity, 9) + + order_line = self._cart_update_order_line( + product_id, quantity, order_line, **kwargs + ) + + if ( + order_line + and order_line.price_unit == 0 + and self.website_id.prevent_zero_price_sale + and product.detailed_type + not in self.env[ + "product.template" + ]._get_product_types_allow_zero_price() + ): + raise UserError( + _( + """The given product does not have a price + therefore it cannot be added to cart.""", + ) + ) + + return { + "line_id": order_line.id, + "quantity": quantity, + "option_ids": list( + set( + order_line.option_line_ids.filtered( + lambda line: line.order_id == order_line.order_id + ).ids + ) + ), + "warning": warning, + } + + def _compute_cart_info(self): + all_sums_are_int = True + for order in self: + total_quantity = sum(order.mapped("website_order_line.product_uom_qty")) + + if not isinstance(total_quantity, int): + all_sums_are_int = False + + only_services = all( + line.product_id.type == "service" for line in order.website_order_line + ) + order.only_services = only_services + if all_sums_are_int: + return super()._compute_cart_info() + else: + for order in self: + order.cart_quantity = sum( + order.mapped("website_order_line.product_uom_qty") + ) + order.only_services = all( + line.product_id.type == "service" + for line in order.website_order_line + ) diff --git a/website_sale_float_cart_qty/static/src/js/float_qty.js b/website_sale_float_cart_qty/static/src/js/float_qty.js new file mode 100644 index 0000000000..ea8d55ef7c --- /dev/null +++ b/website_sale_float_cart_qty/static/src/js/float_qty.js @@ -0,0 +1,86 @@ +odoo.define("website_sale_float_cart_qty.float_qty", function (require) { + "use strict"; + + var publicWidget = require("web.public.widget"); + var wSaleUtils = require("website_sale.utils"); + var core = require("web.core"); + require("website_sale.website_sale"); + + publicWidget.registry.WebsiteSale.include({ + /** + * Override the _changeCartQuantity method of WebsiteSale + * @param {jQuery} $input - The jQuery object representing the input field. + * @param {Number} value - The new value of the input field. + * @param {Array} $dom_optional - Array of DOM elements. + * @param {Number} line_id - The line ID associated with the cart line. + * @param {Array} productIDs - Array of product IDs. + */ + + _changeCartQuantity: function ( + $input, + value, + $dom_optional, + line_id, + productIDs + ) { + _.each($dom_optional, function (elem) { + $(elem).find(".js_quantity").text(value); + productIDs.push( + $(elem).find("span[data-product-id]").data("product-id") + ); + }); + $input.data("update_change", true); + + $input.val($input.val().replace(",", ".")); + + this._rpc({ + route: "/shop/cart/update_json", + params: { + line_id: line_id, + product_id: parseInt($input.data("product-id"), 10), + set_qty: value, + }, + }).then(function (data) { + $input.data("update_change", false); + var check_value = parseFloat($input.val() || 0, 10); + if (isNaN(check_value)) { + check_value = 1; + } + if (value !== check_value) { + $input.trigger("change"); + return; + } + sessionStorage.setItem( + "website_sale_cart_quantity", + data.cart_quantity + ); + if (!data.cart_quantity) { + return (window.location = "/shop/cart"); + } + $input.val(data.quantity); + $(".js_quantity[data-line-id=" + line_id + "]") + .val(data.quantity) + .text(data.quantity); + + wSaleUtils.updateCartNavBar(data); + wSaleUtils.showWarning(data.warning); + // Propagating the change to the express checkout forms + core.bus.trigger("cart_amount_changed", data.amount, data.minor_amount); + }); + }, + + _onChangeCartQuantity: function (ev) { + var $input = $(ev.currentTarget); + if ($input.data("update_change")) { + return; + } + var value = $input.val().replace(",", "."); + value = parseFloat(value); + var $dom = $input.closest("tr"); + var $dom_optional = $dom.nextUntil(":not(.optional_product.info)"); + var line_id = parseInt($input.data("line-id"), 10); + var productIDs = [parseInt($input.data("product-id"), 10)]; + this._changeCartQuantity($input, value, $dom_optional, line_id, productIDs); + }, + }); +}); diff --git a/website_sale_float_cart_qty/tests/__init__.py b/website_sale_float_cart_qty/tests/__init__.py new file mode 100644 index 0000000000..b9d0fe1b97 --- /dev/null +++ b/website_sale_float_cart_qty/tests/__init__.py @@ -0,0 +1 @@ +from . import test_cart_float_qty diff --git a/website_sale_float_cart_qty/tests/test_cart_float_qty.py b/website_sale_float_cart_qty/tests/test_cart_float_qty.py new file mode 100644 index 0000000000..94905d3843 --- /dev/null +++ b/website_sale_float_cart_qty/tests/test_cart_float_qty.py @@ -0,0 +1,163 @@ +import random + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +class TestSaleOrderInherit(TransactionCase): + def setUp(self): + super().setUp() + # Setup a sale order in draft state + + self.website = self.env["website"].create( + { + "name": "Test Website", + } + ) + + self.product = self.env["product.product"].create( + { + "name": "Test Product", + "type": "consu", # assuming 'consu' type is allowed to add to cart + "list_price": 100.0, + } + ) + + self.sale_order = self.env["sale.order"].create( + { + "partner_id": self.env.ref("base.res_partner_1").id, + "website_id": self.website.id, + } + ) + + self.sale_order_line = self.env["sale.order.line"].create( + { + "order_id": self.sale_order.id, + "product_id": self.product.id, + "product_uom_qty": 1, + "price_unit": self.product.list_price, + } + ) + + def test_cart_update_add_product(self): + """Test that a product can be added to the cart""" + # Adding quantity to the product + result = self.sale_order._cart_update(product_id=self.product.id, add_qty=2) + + self.assertEqual( + result["quantity"], 3, "Product quantity should be updated to 3" + ) + self.assertEqual( + self.sale_order.cart_quantity, 3, "Cart quantity should be updated to 3" + ) + + def test_cart_update_add_float_product(self): + """Test that a float quantity can be added to the cart""" + # Adding float quantity to the product + result = self.sale_order._cart_update( + product_id=self.product.id, add_qty=2.2, set_qty=False + ) + + self.assertEqual( + result["quantity"], 3.2, "Product quantity should be updated to 3.2" + ) + self.assertEqual( + self.sale_order.cart_quantity, 3.2, "Cart quantity should be updated to 3.2" + ) + + def test_cart_update_set_quantity(self): + """Test that setting the quantity works""" + # Setting quantity of the product + result = self.sale_order._cart_update(product_id=self.product.id, set_qty=5) + + self.assertEqual(result["quantity"], 5, "Product quantity should be set to 5") + self.assertEqual( + self.sale_order.cart_quantity, 5, "Cart quantity should be updated to 5" + ) + + def test_cart_update_set_float_quantity(self): + """Test that setting float quantity works""" + # Setting float quantity of the product + result = self.sale_order._cart_update( + product_id=self.product.id, set_qty=9.95, add_qty=None + ) + + self.assertEqual( + result["quantity"], 9.95, "Product quantity should be set to 9.95" + ) + self.sale_order.cart_quantity = round(self.sale_order.cart_quantity, 9) + + self.assertEqual( + self.sale_order.cart_quantity, + 9.95, + "Cart quantity should be updated to 9.95", + ) + + def test_cart_update_remove_product(self): + """Test that removing a product works""" + # Removing the product by setting quantity to 0 + result = self.sale_order._cart_update( + product_id=self.product.id, set_qty=0, add_qty=None + ) + + self.assertEqual(result["quantity"], 0, "Product quantity should be 0") + self.assertEqual(self.sale_order.cart_quantity, 0, "Cart should be empty") + + def _generate_non_existent_product_id(self): + """Generate a non-existent product ID.""" + while True: + random_id = random.randint(1, 9999999) # Generate a random product ID + product = self.env["product.product"].browse(random_id) + if not product.exists(): + return random_id + + def test_cart_update_non_existent_product(self): + """Test that updating the cart with a non-existent product raises an error""" + # Generate a random non-existent product ID + non_existent_product_id = self._generate_non_existent_product_id() + + # Check if product exists in the database + product = self.env["product.product"].browse(non_existent_product_id) + self.assertFalse(product.exists(), "Product should not exist in the database") + + # Try updating the cart with a non-existent product + with self.assertRaises( + UserError, msg="Should raise UserError for non-existent product" + ): + self.sale_order._cart_update(product_id=non_existent_product_id, add_qty=1) + + def test_cart_update_zero_price_product(self): + """Test that adding a zero-price product raises an error""" + zero_price_product = self.env["product.product"].create( + { + "name": "Zero Price Product", + "type": "consu", + "list_price": 0.0, + "detailed_type": "consu", + } + ) + + self.sale_order.website_id.prevent_zero_price_sale = True + + with self.assertRaises( + UserError, msg="Should raise UserError for zero-price product" + ): + self.sale_order._cart_update(product_id=zero_price_product.id, add_qty=1) + + def test_cart_update_without_line_id(self): + """Test the _cart_update method when line_id is False.""" + # Call the _cart_update method with line_id set to False + # This should trigger the else branch and use the empty order line. + result = self.sale_order._cart_update( + product_id=self.product.id, + line_id=False, + add_qty=1, + set_qty=False, + ) + + # that an empty sale.order.line is returned as there should be no matching line. + self.assertNotEqual( + result.get("line_id"), + False, + "line_id should not be False as it represents an empty recordset", + )