From 49cb2b3928f1d2bfb9d63dd3429846fd06ead3b0 Mon Sep 17 00:00:00 2001 From: Unai Beristain Date: Wed, 21 Aug 2024 12:59:32 +0200 Subject: [PATCH] [ADD] website_sale_cart_quantity_shop: Add plus and minus buttons in product list in cart --- .../addons/website_sale_cart_quantity_shop | 1 + .../website_sale_cart_quantity_shop/setup.py | 6 + website_sale_cart_quantity_shop/README.rst | 59 +++++ website_sale_cart_quantity_shop/__init__.py | 5 + .../__manifest__.py | 24 ++ .../controllers/__init__.py | 4 + .../controllers/website_sale.py | 115 +++++++++ website_sale_cart_quantity_shop/hooks.py | 14 ++ .../static/src/js/recalculate_product_qty.js | 179 ++++++++++++++ .../tests/__init__.py | 1 + .../tests/test_website_sale_controller.py | 218 ++++++++++++++++++ .../views/assets.xml | 14 ++ .../views/website_sale.xml | 63 +++++ 13 files changed, 703 insertions(+) create mode 120000 setup/website_sale_cart_quantity_shop/odoo/addons/website_sale_cart_quantity_shop create mode 100644 setup/website_sale_cart_quantity_shop/setup.py create mode 100644 website_sale_cart_quantity_shop/README.rst create mode 100644 website_sale_cart_quantity_shop/__init__.py create mode 100644 website_sale_cart_quantity_shop/__manifest__.py create mode 100644 website_sale_cart_quantity_shop/controllers/__init__.py create mode 100644 website_sale_cart_quantity_shop/controllers/website_sale.py create mode 100644 website_sale_cart_quantity_shop/hooks.py create mode 100644 website_sale_cart_quantity_shop/static/src/js/recalculate_product_qty.js create mode 100644 website_sale_cart_quantity_shop/tests/__init__.py create mode 100644 website_sale_cart_quantity_shop/tests/test_website_sale_controller.py create mode 100644 website_sale_cart_quantity_shop/views/assets.xml create mode 100644 website_sale_cart_quantity_shop/views/website_sale.xml diff --git a/setup/website_sale_cart_quantity_shop/odoo/addons/website_sale_cart_quantity_shop b/setup/website_sale_cart_quantity_shop/odoo/addons/website_sale_cart_quantity_shop new file mode 120000 index 0000000000..7dc113e740 --- /dev/null +++ b/setup/website_sale_cart_quantity_shop/odoo/addons/website_sale_cart_quantity_shop @@ -0,0 +1 @@ +../../../../website_sale_cart_quantity_shop \ No newline at end of file diff --git a/setup/website_sale_cart_quantity_shop/setup.py b/setup/website_sale_cart_quantity_shop/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/website_sale_cart_quantity_shop/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/website_sale_cart_quantity_shop/README.rst b/website_sale_cart_quantity_shop/README.rst new file mode 100644 index 0000000000..b444942c4b --- /dev/null +++ b/website_sale_cart_quantity_shop/README.rst @@ -0,0 +1,59 @@ +.. image:: https://pbs.twimg.com/profile_images/547133733149483008/0JKHr3Av_400x400.png + :target: https://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +Website Sale Cart Quantity Shop +======================================= + +- **Plus and Minus Buttons**: + - Users can increase or decrease the quantity of a product by clicking the plus (+) or minus (−) buttons next to the quantity input field. + - Clicking the plus button increments the quantity by 1. + - Clicking the minus button decrements the quantity by 1, with a minimum value of 0. + +- **Direct Input**: + - Users can manually enter the desired quantity directly into the input box in the middle of the buttons. + - The input field validates and updates the quantity based on user input. + +- **Dynamic Updates**: + - The quantity input field dynamically updates the cart when the quantity is changed using either the buttons or by direct input. + - The system ensures that the quantity displayed is in sync with the quantity available in the cart. + +- **Visual Feedback**: + - The input field's background color and text color change when the quantity matches the available stock, providing visual feedback to the user. + +Usage +===== + +1. **Navigate to the Shop Page**: + - Go to your shop or category page in the Odoo eCommerce interface. + +2. **Adjust Product Quantity**: + - Use the plus (+) button to increase the quantity by 1. + - Use the minus (−) button to decrease the quantity by 1, ensuring the quantity does not drop below 0. + - Enter a specific quantity directly into the input field to set the desired amount. + +3. **Visual Feedback**: + - Observe changes in the input field color to reflect the available stock. + +Configuration +============= + +No additional configuration is required. The module integrates seamlessly with the existing product quantity functionality on the shop page. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. If you encounter any issues, please check there to see if your issue has already been reported. If not, provide detailed feedback to help us resolve it. + +Credits +======= + +Contributors +------------ +* Unai Beristain + +Do not contact contributors directly about support or help with technical issues. + +License +======= +This project is licensed under the AGPL-3 License. For more details, please refer to the LICENSE file or visit . diff --git a/website_sale_cart_quantity_shop/__init__.py b/website_sale_cart_quantity_shop/__init__.py new file mode 100644 index 0000000000..fef015afb6 --- /dev/null +++ b/website_sale_cart_quantity_shop/__init__.py @@ -0,0 +1,5 @@ +############################################################################### +# For copyright and license notices, see __manifest__.py file in root directory +############################################################################### +from .hooks import pre_init_hook +from . import controllers diff --git a/website_sale_cart_quantity_shop/__manifest__.py b/website_sale_cart_quantity_shop/__manifest__.py new file mode 100644 index 0000000000..61438928bb --- /dev/null +++ b/website_sale_cart_quantity_shop/__manifest__.py @@ -0,0 +1,24 @@ +# Ooops +# Cetmix +# Copyright 2024 Unai Beristain - AvanzOSC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "Website Sale Cart Quantity Shop", + "summary": "Choose cart quantity from shop page", + "category": "Website", + "version": "14.0.1.1.0", + "author": "AvanzOSC, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/e-commerce", + "license": "AGPL-3", + "depends": [ + "stock", + "website_sale", + ], + "data": [ + "views/assets.xml", + "views/website_sale.xml", + ], + "installable": True, + "pre_init_hook": "pre_init_hook", +} diff --git a/website_sale_cart_quantity_shop/controllers/__init__.py b/website_sale_cart_quantity_shop/controllers/__init__.py new file mode 100644 index 0000000000..1b65f0ff11 --- /dev/null +++ b/website_sale_cart_quantity_shop/controllers/__init__.py @@ -0,0 +1,4 @@ +############################################################################### +# For copyright and license notices, see __manifest__.py file in root directory +############################################################################### +from . import website_sale diff --git a/website_sale_cart_quantity_shop/controllers/website_sale.py b/website_sale_cart_quantity_shop/controllers/website_sale.py new file mode 100644 index 0000000000..b2ac75a146 --- /dev/null +++ b/website_sale_cart_quantity_shop/controllers/website_sale.py @@ -0,0 +1,115 @@ +# Copyright 2024 Unai Beristain - AvanzOSC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import json + +from odoo import fields +from odoo.http import request, route + +from odoo.addons.website_sale.controllers.main import WebsiteSaleForm + + +class WebsiteSaleForm(WebsiteSaleForm): + @route( + ["/shop/cart/update_json_from_shop"], + type="json", + auth="public", + methods=["POST"], + website=True, + csrf=False, + ) + def cart_update_json_from_shop( + self, + product_id=None, + line_id=None, + add_qty=None, + set_qty=None, + display=True, + **kw + ): + + # Needed for the test, because the test works in a different wat + data = request.jsonrequest + product_id = data.get("product_id") if not product_id else product_id + line_id = data.get("line_id") if not line_id else line_id + add_qty = data.get("add_qty", 1) if not add_qty else add_qty + set_qty = data.get("set_qty", 0) if not set_qty else set_qty + display = data.get("display") if data.get("display") is not None else display + + sale_order = request.website.sale_get_order(force_create=True) + + if sale_order.state != "draft": + request.session["sale_order_id"] = None + sale_order = request.website.sale_get_order(force_create=True) + + product_custom_attribute_values = None + if kw.get("product_custom_attribute_values"): + product_custom_attribute_values = json.loads( + kw.get("product_custom_attribute_values") + ) + + no_variant_attribute_values = None + if kw.get("no_variant_attribute_values"): + no_variant_attribute_values = json.loads( + kw.get("no_variant_attribute_values") + ) + + value = sale_order._cart_update( + product_id=product_id, + line_id=line_id, + add_qty=add_qty, + set_qty=set_qty, + product_custom_attribute_values=product_custom_attribute_values, + no_variant_attribute_values=no_variant_attribute_values, + ) + value["cart_quantity"] = sale_order.cart_quantity + if not sale_order.cart_quantity: + request.website.sale_reset() + return value + + if not display: + return value + + value["website_sale.cart_lines"] = request.env["ir.ui.view"]._render_template( + "website_sale.cart_lines", + { + "website_sale_order": sale_order, + "date": fields.Date.today(), + "suggested_products": sale_order._cart_accessories(), + }, + ) + value["website_sale.short_cart_summary"] = request.env[ + "ir.ui.view" + ]._render_template( + "website_sale.short_cart_summary", + { + "website_sale_order": sale_order, + }, + ) + + order_line = ( + sale_order.sudo().order_line.filtered( + lambda line: line.product_id.id == product_id + ) + if sale_order and sale_order.order_line + else [] + ) + + value["product_cart_qty"] = ( + int(order_line[0].sudo().product_uom_qty) + if order_line and order_line[0].product_uom_qty + else 0 + ) + + product = request.env["product.product"].sudo().browse(product_id) + value["product_available_qty"] = product.qty_available - product.outgoing_qty + + return value + + # Controller needed for testing all lines + @route("/set_sale_order_id", type="json", auth="public", methods=["POST"]) + def set_sale_order_id(self): + data = request.jsonrequest + sale_order_id = data.get("sale_order_id") + request.session["sale_order_id"] = sale_order_id + return {"status": "success", "message": "Sale order ID set in session"} diff --git a/website_sale_cart_quantity_shop/hooks.py b/website_sale_cart_quantity_shop/hooks.py new file mode 100644 index 0000000000..22f98c3df1 --- /dev/null +++ b/website_sale_cart_quantity_shop/hooks.py @@ -0,0 +1,14 @@ +# Copyright 2024 Unai Beristain - AvanzOSC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +# Add to Cart button on product page +# Necessary to know product_id to add to cart +def pre_init_hook(cr): + cr.execute( + """ + UPDATE ir_ui_view + SET active = TRUE + WHERE key = 'website_sale.products_add_to_cart' + """ + ) diff --git a/website_sale_cart_quantity_shop/static/src/js/recalculate_product_qty.js b/website_sale_cart_quantity_shop/static/src/js/recalculate_product_qty.js new file mode 100644 index 0000000000..6b817e20ef --- /dev/null +++ b/website_sale_cart_quantity_shop/static/src/js/recalculate_product_qty.js @@ -0,0 +1,179 @@ +// Copyright 2024 Unai Beristain - AvanzOSC +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +odoo.define("website_sale_cart_quantity_shop.recalculate_product_qty", function ( + require +) { + "use strict"; + + const publicWidget = require("web.public.widget"); + const wSaleUtils = require("website_sale.utils"); + + // Define a flag to track if an RPC call is in progress + let rpcInProgress = false; + + function isCategoryPage() { + const url = window.location.href; + return ( + url.includes("/category/") || + (url.includes("/shop") && !url.includes("/shop/")) || + url.includes("/shop/page/") + ); + } + + if (isCategoryPage()) { + $(document).ready(function () { + $("input.form-control.quantity").each(function () { + var newValue = parseInt($(this).val().replace(",", "."), 10) || 0; + $(this).val(newValue); + $(this).data("oldValue", newValue); + }); + }); + + publicWidget.registry.WebsiteSale.include({ + custom_add_qty: 0, + changeTriggeredByButton: false, + modifiedInputField: null, + oldValue: 0, + newValue: 0, + + start: function () { + this._super.apply(this, arguments); + var self = this; + + $(".fa.fa-plus") + .parent() + .click(function (event) { + event.preventDefault(); + var inputField = $(this) + .parent() + .siblings("input.form-control.quantity"); + self.modifiedInputField = inputField; + self.oldValue = parseInt(inputField.val(), 10) || 0; + self.newValue = self.oldValue + 1; + inputField.data("oldValue", self.newValue); + self.custom_add_qty = 1; + self.changeTriggeredByButton = true; + self._onClickAdd(event); + }); + + $(".fa.fa-minus") + .parent() + .click(function (event) { + event.preventDefault(); + var inputField = $(this) + .parent() + .siblings("input.form-control.quantity"); + self.modifiedInputField = inputField; + self.oldValue = parseInt(inputField.val(), 10) || 0; + self.newValue = Math.max(self.oldValue - 1, 0); + inputField.data("oldValue", self.newValue); + self.custom_add_qty = -1; + self.changeTriggeredByButton = true; + self._onClickAdd(event); + }); + + $("input.form-control.quantity").on("change", function (event) { + self.modifiedInputField = $(this); + + if (self.changeTriggeredByButton) { + self.changeTriggeredByButton = false; + } else { + self.oldValue = $(this).data("oldValue") || 0; + self.newValue = + parseInt($(this).val().replace(",", "."), 10) || 0; + + if (self.newValue < 0 || isNaN(self.newValue)) { + self.newValue = 0; + } + + $(this).val(self.newValue); + $(this).data("oldValue", self.newValue); + self.custom_add_qty = self.newValue - self.oldValue; + self._onClickAdd(event); + } + }); + }, + + _onClickAdd: function (ev) { + this.isDynamic = true; + this.pageType = $(ev.currentTarget).data("page-type"); + this.targetEl = $(ev.currentTarget); + this._super.apply(this, arguments); + }, + + _submitForm: function () { + if (rpcInProgress) { + console.log( + "An RPC call is already in progress. Skipping this call." + ); + return Promise.resolve(); + } + + rpcInProgress = true; + + const self = this; + const params = this.rootProduct; + params.add_qty = this.custom_add_qty; + + const $inputField = this.modifiedInputField; + + params.product_custom_attribute_values = JSON.stringify( + params.product_custom_attribute_values + ); + params.no_variant_attribute_values = JSON.stringify( + params.no_variant_attribute_values + ); + + if (this.isBuyNow) { + params.express = true; + } + + return this._rpc({ + route: "/shop/cart/update_json_from_shop", + params: params, + }) + .then((data) => { + self.oldValue = parseInt($inputField.val(), 10) || 0; + self.newValue = parseInt(data.product_cart_qty, 10) || 0; + $inputField.data("oldValue", self.newValue); + $inputField.val(self.newValue); + self.changeTriggeredByButton = true; + + if ( + data.product_cart_qty == data.product_available_qty && + data.product_cart_qty != 0 + ) { + $inputField.css({ + color: "white", + "background-color": "black", + "font-weight": "bold", + }); + } else { + $inputField.css({ + color: "black", + "background-color": "white", + "font-weight": "normal", + }); + } + + wSaleUtils.updateCartNavBar(data); + const $navButton = $("header .o_wsale_my_cart").parent(); + let el = $(); + if (self.pageType === "product") { + el = $("#o-carousel-product"); + } + if (self.pageType === "products") { + el = self.targetEl.parents(".o_wsale_product_grid_wrapper"); + } + wSaleUtils.animateClone($navButton, el, 25, 40); + + rpcInProgress = false; + }) + .catch((error) => { + console.error("Error occurred during RPC call:", error); + rpcInProgress = false; + }); + }, + }); + } +}); diff --git a/website_sale_cart_quantity_shop/tests/__init__.py b/website_sale_cart_quantity_shop/tests/__init__.py new file mode 100644 index 0000000000..7a98bbdd21 --- /dev/null +++ b/website_sale_cart_quantity_shop/tests/__init__.py @@ -0,0 +1 @@ +from . import test_website_sale_controller diff --git a/website_sale_cart_quantity_shop/tests/test_website_sale_controller.py b/website_sale_cart_quantity_shop/tests/test_website_sale_controller.py new file mode 100644 index 0000000000..5ecca345a0 --- /dev/null +++ b/website_sale_cart_quantity_shop/tests/test_website_sale_controller.py @@ -0,0 +1,218 @@ +import json + +from odoo.tests.common import HttpCase + + +class TestWebsiteSaleCartUpdate(HttpCase): + def setUp(self): + super(TestWebsiteSaleCartUpdate, self).setUp() + # Create a test product + self.product = self.env["product.product"].create( + { + "name": "Test Product", + "list_price": 10.0, + "type": "product", + } + ) + + # Create a test sale order and add the product + self.sale_order = self.env["sale.order"].create( + { + "partner_id": self.env.ref("base.res_partner_1").id, + "state": "draft", + } + ) + + # Add a line to the sale order + self.sale_order_line = self.env["sale.order.line"].create( + { + "order_id": self.sale_order.id, + "product_id": self.product.id, + "name": "Test Product Line", + "product_uom_qty": 1.0, + "price_unit": 10.0, + } + ) + + def test_cart_update_json_from_shop_add_qty(self): + """Test the cart_update_json_from_shop route""" + + # Prepare the request data with the required 'product_id' and 'line_id' + data = { + "product_id": self.product.id, # Make sure 'product_id' is included + "line_id": self.sale_order_line.id, # Include 'line_id' + "add_qty": 5, + "set_qty": 0, + "display": True, + } + + # Send the POST request with the proper data + response = self.url_open( + "/shop/cart/update_json_from_shop", + data=json.dumps(data), # Convert data to JSON + headers={"Content-Type": "application/json"}, + timeout=1000, + ) + + response_json = response.json() + result = response_json.get("result", {}) + + # Check for presence and value of 'line_id' + self.assertIn("line_id", result) + self.assertEqual(result["line_id"], self.sale_order_line.id + 1) + + # Check for presence and value of 'cart_quantity' + self.assertIn("cart_quantity", result) + self.assertEqual(result["cart_quantity"], 5) + # You have to put 5 instead of 6 because it works differently when it's a test + + # Check for presence of 'website_sale.cart_lines' + self.assertIn("website_sale.cart_lines", result) + self.assertTrue( + result["website_sale.cart_lines"].strip() != "", + "Cart lines HTML is empty", + ) + + # Check for presence of 'website_sale.short_cart_summary' + self.assertIn("website_sale.short_cart_summary", result) + self.assertTrue( + result["website_sale.short_cart_summary"].strip() != "", + "Short cart summary HTML is empty", + ) + + # Check for presence and value of 'product_cart_qty' + self.assertIn("product_cart_qty", result) + self.assertEqual(result["product_cart_qty"], 5) + # You have to put 5 instead of 6 because it works differently when it's a test + + def test_cart_update_json_from_shop_set_qty(self): + """Test the cart_update_json_from_shop route""" + + # Prepare the request data with the required 'product_id' and 'line_id' + data = { + "product_id": self.product.id, # Make sure 'product_id' is included + "line_id": self.sale_order_line.id, # Include 'line_id' + "add_qty": 0, + "set_qty": 2, + "display": True, + } + + # Send the POST request with the proper data + response = self.url_open( + "/shop/cart/update_json_from_shop", + data=json.dumps(data), + headers={"Content-Type": "application/json"}, + timeout=1000, + ) + + response_json = response.json() + result = response_json.get("result", {}) + + # Check for presence and value of 'line_id' + self.assertIn("line_id", result) + self.assertEqual(result["line_id"], self.sale_order_line.id + 1) + + # Check for presence and value of 'cart_quantity' + self.assertIn("cart_quantity", result) + self.assertEqual(result["cart_quantity"], 2) + + # Check for presence of 'website_sale.cart_lines' + self.assertIn("website_sale.cart_lines", result) + self.assertTrue( + result["website_sale.cart_lines"].strip() != "", + "Cart lines HTML is empty", + ) + + # Check for presence of 'website_sale.short_cart_summary' + self.assertIn("website_sale.short_cart_summary", result) + self.assertTrue( + result["website_sale.short_cart_summary"].strip() != "", + "Short cart summary HTML is empty", + ) + + # Check for presence and value of 'product_cart_qty' + self.assertIn("product_cart_qty", result) + self.assertEqual(result["product_cart_qty"], 2) + + def test_order_not_draft(self): + # Confirm the sale order + self.sale_order.action_confirm() + + # Set the sale_order_id in the session + set_sale_order_url = "/set_sale_order_id?sale_order_id=%d" % self.sale_order.id + data = { + "sale_order_id": self.sale_order.id, + } + response = self.url_open( + set_sale_order_url, + data=json.dumps(data), + headers={"Content-Type": "application/json"}, + ) + + # Prepare the data for updating the cart + data = { + "product_id": self.product.id, + "line_id": self.sale_order_line.id, + "add_qty": 0, + "set_qty": 2, + "display": True, + } + + # Use url_open to call the update_json_from_shop endpoint + response = self.url_open( + "/shop/cart/update_json_from_shop", + data=json.dumps(data), + headers={"Content-Type": "application/json"}, + ) + json.loads(response.text) + + # Fetch the sale order again to check its state + self.sale_order.refresh() + self.assertEqual(self.sale_order.state, "sale") + + def test_display_false(self): + + data = { + "product_id": self.product.id, + "line_id": self.sale_order_line.id, + "add_qty": 0, + "set_qty": 2, + "display": False, + } + + self.url_open( + "/shop/cart/update_json_from_shop", + data=json.dumps(data), + headers={"Content-Type": "application/json"}, + timeout=1000, + ) + + def test_sale_order_cart_quantity_false(self): + for line in self.sale_order.order_line: + line.product_uom_qty = 0 + + # Set the sale_order_id in the session + set_sale_order_url = "/set_sale_order_id?sale_order_id=%d" % self.sale_order.id + data = { + "sale_order_id": self.sale_order.id, + } + self.url_open( + set_sale_order_url, + data=json.dumps(data), + headers={"Content-Type": "application/json"}, + ) + + data = { + "product_id": self.product.id, + "line_id": self.sale_order_line.id, + "add_qty": 0, + "set_qty": 0, + "display": True, + } + + self.url_open( + "/shop/cart/update_json_from_shop", + data=json.dumps(data), + headers={"Content-Type": "application/json"}, + timeout=1000, + ) diff --git a/website_sale_cart_quantity_shop/views/assets.xml b/website_sale_cart_quantity_shop/views/assets.xml new file mode 100644 index 0000000000..2f6c32298f --- /dev/null +++ b/website_sale_cart_quantity_shop/views/assets.xml @@ -0,0 +1,14 @@ + +