From 4069158c96bbf2a6357622d5c0b0a91d722dd4cb Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Thu, 23 Feb 2023 16:31:45 +0100 Subject: [PATCH 1/8] [REF] sale,*: combination_info logic Clean all code related to _get_combination_info: * Move logic to website_sale now that backend configurator doesn't rely on it anymore. Only frontend configurator uses that logic. * Make sure the returned information is correct and consistent: * request must be given the right context (multi-company) * behavior is coherent between modules and overrides (date, company, currency, rounding) * Performance: * Avoid loading unnecessary information * Avoid calling _get_combination_info when only part of the data is necessary. * Clean/simplify uses and overrides by keeping useful information in the combination_info dict (taxes, currency, ...) * Code cleanup * clean imports * clean architecture * clean templates * move old configurator code to website_sale Now that the product configurator backend side has been redone completely, the remaining parts previously shared between backend and frontend configurators can be moved to website_sale because only used by the frontend configurator. Part-of: odoo/odoo#121986 --- addons/sale/models/product_product.py | 16 +- addons/sale/models/product_template.py | 182 -------- ...est_sale_product_attribute_value_config.py | 149 +------ addons/website_sale/controllers/main.py | 22 +- addons/website_sale/controllers/variant.py | 60 ++- addons/website_sale/models/__init__.py | 1 + addons/website_sale/models/product_product.py | 9 + .../website_sale/models/product_template.py | 403 +++++++++++++----- .../product_template_attribute_value.py | 38 ++ addons/website_sale/models/website.py | 18 +- .../static/src/js/sale_variant_mixin.js | 8 +- .../static/src/js/variant_mixin.js | 14 - .../static/src/js/website_sale_reorder.js | 3 +- .../tests/test_website_sale_pricelist.py | 11 +- .../tests/test_website_sale_product.py | 4 +- ...ite_sale_product_attribute_value_config.py | 21 +- addons/website_sale/views/templates.xml | 73 ++-- .../website_sale/views/variant_templates.xml | 40 +- .../website_sale_comparison_template.xml | 45 +- .../__manifest__.py | 3 - .../controllers/__init__.py | 2 +- .../controllers/main.py | 140 +++--- .../controllers/sale_product_configurator.py | 94 ---- .../controllers/website_sale.py | 72 ++++ .../src/js/product_configurator_modal.js | 36 -- .../src/js/sale_product_configurator_modal.js | 50 ++- .../static/src/js/website_sale_options.js | 35 +- .../views/templates.xml | 141 ++---- ...te_sale_product_configurator_templates.xml | 16 - .../website_sale_slides/controllers/slides.py | 4 +- .../views/website_slides_templates.xml | 18 +- .../website_sale_stock/controllers/variant.py | 14 +- .../models/product_product.py | 7 +- .../models/product_template.py | 64 ++- .../website_sale_stock/models/sale_order.py | 5 +- addons/website_sale_stock/models/website.py | 8 +- ...st_website_sale_stock_product_warehouse.py | 23 +- .../controllers/variant.py | 14 +- .../models/product_template.py | 28 +- .../website_sale_wishlist/controllers/main.py | 2 +- .../views/website_sale_wishlist_template.xml | 9 +- 41 files changed, 837 insertions(+), 1065 deletions(-) create mode 100644 addons/website_sale/models/product_template_attribute_value.py delete mode 100644 addons/website_sale_product_configurator/controllers/sale_product_configurator.py create mode 100644 addons/website_sale_product_configurator/controllers/website_sale.py delete mode 100644 addons/website_sale_product_configurator/static/src/js/product_configurator_modal.js delete mode 100644 addons/website_sale_product_configurator/views/website_sale_product_configurator_templates.xml diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py index 2593b4674c2..77ba4fa433f 100644 --- a/addons/sale/models/product_product.py +++ b/addons/sale/models/product_product.py @@ -1,9 +1,10 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import timedelta, time -from odoo.exceptions import UserError + +from datetime import time, timedelta + from odoo import api, fields, models, _ -from odoo.tools.float_utils import float_round +from odoo.exceptions import UserError +from odoo.tools import float_round class ProductProduct(models.Model): @@ -88,13 +89,6 @@ class ProductProduct(models.Model): def _get_invoice_policy(self): return self.invoice_policy - def _get_combination_info_variant(self, add_qty=1, pricelist=False, parent_combination=False): - """Return the variant info based on its combination. - See `_get_combination_info` for more information. - """ - self.ensure_one() - return self.product_tmpl_id._get_combination_info(self.product_template_attribute_value_ids, self.id, add_qty, pricelist, parent_combination) - def _filter_to_unlink(self): domain = [('product_id', 'in', self.ids)] lines = self.env['sale.order.line']._read_group(domain, ['product_id']) diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py index b7796332a7b..a8a89f2a52a 100644 --- a/addons/sale/models/product_template.py +++ b/addons/sale/models/product_template.py @@ -91,42 +91,6 @@ class ProductTemplate(models.Model): } return action - def create_product_variant(self, product_template_attribute_value_ids): - """ Create if necessary and possible and return the id of the product - variant matching the given combination for this template. - - Note AWA: Known "exploit" issues with this method: - - - This method could be used by an unauthenticated user to generate a - lot of useless variants. Unfortunately, after discussing the - matter with ODO, there's no easy and user-friendly way to block - that behavior. - - We would have to use captcha/server actions to clean/... that - are all not user-friendly/overkill mechanisms. - - - This method could be used to try to guess what product variant ids - are created in the system and what product template ids are - configured as "dynamic", but that does not seem like a big deal. - - The error messages are identical on purpose to avoid giving too much - information to a potential attacker: - - returning 0 when failing - - returning the variant id whether it already existed or not - - :param product_template_attribute_value_ids: the combination for which - to get or create variant - :type product_template_attribute_value_ids: list of id - of `product.template.attribute.value` - - :return: id of the product variant matching the combination or 0 - :rtype: int - """ - combination = self.env['product.template.attribute.value'] \ - .browse(product_template_attribute_value_ids) - - return self._create_product_variant(combination, log_warning=True).id or 0 - @api.onchange('type') def _onchange_type(self): res = super(ProductTemplate, self)._onchange_type() @@ -155,149 +119,3 @@ class ProductTemplate(models.Model): 'template': '/product/static/xls/product_template.xls' }] return res - - def _get_combination_info( - self, combination=False, product_id=False, add_qty=1, pricelist=False, - parent_combination=False, only_template=False - ): - """ Return info about a given combination. - - Note: this method does not take into account whether the combination is - actually possible. - - :param combination: recordset of `product.template.attribute.value` - - :param product_id: id of a `product.product`. If no `combination` - is set, the method will try to load the variant `product_id` if - it exists instead of finding a variant based on the combination. - - If there is no combination, that means we definitely want a - variant and not something that will have no_variant set. - - :param add_qty: float with the quantity for which to get the info, - indeed some pricelist rules might depend on it. - - :param pricelist: `product.pricelist` the pricelist to use - (can be none, eg. from SO if no partner and no pricelist selected) - - :param parent_combination: if no combination and no product_id are - given, it will try to find the first possible combination, taking - into account parent_combination (if set) for the exclusion rules. - - :param only_template: boolean, if set to True, get the info for the - template only: ignore combination and don't try to find variant - - :return: dict with product/combination info: - - - product_id: the variant id matching the combination (if it exists) - - - product_template_id: the current template id - - - display_name: the name of the combination - - - price: the computed price of the combination, take the catalog - price if no pricelist is given - - - list_price: the catalog price of the combination, but this is - not the "real" list_price, it has price_extra included (so - it's actually more closely related to `lst_price`), and it - is converted to the pricelist currency (if given) - - - has_discounted_price: True if the pricelist discount policy says - the price does not include the discount and there is actually a - discount applied (price < list_price), else False - """ - self.ensure_one() - - quantity = self.env.context.get('quantity', add_qty) - product_template = self - - combination = combination or product_template.env['product.template.attribute.value'] - - pricelist = pricelist or self.env['product.pricelist'] - - if not product_id and not combination and not only_template: - combination = product_template._get_first_possible_combination(parent_combination) - - if only_template: - product = product_template.env['product.product'] - elif product_id and not combination: - product = product_template.env['product.product'].browse(product_id) - else: - product = product_template._get_variant_for_combination(combination) - - if product: - display_image = bool(product.image_128) - display_name = product.display_name - else: - display_image = bool(product_template.image_128) - display_name = product_template.display_name - - combination_name = combination._get_combination_name() - if combination_name: - display_name = "%s (%s)" % (display_name, combination_name) - - product_or_template = product or self - - price_context = product_or_template._get_product_price_context(combination) - product_or_template = product_or_template.with_context(**price_context) - - list_price = product_or_template._price_compute('list_price')[product_or_template.id] - price_extra = product_or_template._get_attributes_extra_price() - price = pricelist._get_product_price(product_or_template, quantity) - - currency = pricelist.currency_id or self.env.company.currency_id - if currency != product_template.currency_id: - list_price = product_template.currency_id._convert( - list_price, currency, product_template._get_current_company(pricelist=pricelist), - fields.Date.today() - ) - price_extra = product_template.currency_id._convert( - price_extra, currency, product_template._get_current_company(pricelist=pricelist), - fields.Date.today() - ) - - price_without_discount = list_price if pricelist and pricelist.discount_policy == 'without_discount' else price - has_discounted_price = currency.compare_amounts(price_without_discount, price) == 1 - - return { - 'product_id': product.id, - 'product_template_id': product_template.id, - 'display_name': display_name, - 'display_image': display_image, - 'price': price, - 'list_price': list_price, - 'price_extra': price_extra, - 'has_discounted_price': has_discounted_price, - } - - def _can_be_added_to_cart(self): - """ - Pre-check to `_is_add_to_cart_possible` to know if product can be sold. - """ - return self.sale_ok - - def _is_add_to_cart_possible(self, parent_combination=None): - """ - It's possible to add to cart (potentially after configuration) if - there is at least one possible combination. - - :param parent_combination: the combination from which `self` is an - optional or accessory product. - :type parent_combination: recordset `product.template.attribute.value` - - :return: True if it's possible to add to cart, else False - :rtype: bool - """ - self.ensure_one() - if not self.active or not self._can_be_added_to_cart(): - # for performance: avoid calling `_get_possible_combinations` - return False - return next(self._get_possible_combinations(parent_combination), False) is not False - - def _get_current_company_fallback(self, **kwargs): - """Override: if a pricelist is given, fallback to the company of the - pricelist if it is set, otherwise use the one from parent method.""" - res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs) - pricelist = kwargs.get('pricelist') - return pricelist and pricelist.company_id or res diff --git a/addons/sale/tests/test_sale_product_attribute_value_config.py b/addons/sale/tests/test_sale_product_attribute_value_config.py index 48f134649c7..d7c5c4f6b2f 100644 --- a/addons/sale/tests/test_sale_product_attribute_value_config.py +++ b/addons/sale/tests/test_sale_product_attribute_value_config.py @@ -198,153 +198,8 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueCommon): self.assertEqual(computer_ssd_256_after.attribute_line_id, computer_ssd_256_before.attribute_line_id) do_test(self) - def test_02_get_combination_info(self): - # If using multi-company, company_id will be False, and this code should - # still work. - # The case with a company_id will be implicitly tested on website_sale. - self.computer.company_id = False - - computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) - computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) - computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) - - # CASE: no pricelist, no currency, with existing combination, with price_extra on attributes - combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 - computer_variant = self.computer._get_variant_for_combination(combination) - - res = self.computer._get_combination_info(combination) - self.assertEqual(res['product_template_id'], self.computer.id) - self.assertEqual(res['product_id'], computer_variant.id) - self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)") - self.assertEqual(res['price'], 2222) - self.assertEqual(res['list_price'], 2222) - self.assertEqual(res['price_extra'], 222) - - # CASE: no combination, product given - res = self.computer._get_combination_info(self.env['product.template.attribute.value'], computer_variant.id) - self.assertEqual(res['product_template_id'], self.computer.id) - self.assertEqual(res['product_id'], computer_variant.id) - self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)") - self.assertEqual(res['price'], 2222) - self.assertEqual(res['list_price'], 2222) - self.assertEqual(res['price_extra'], 222) - - # CASE: using pricelist, quantity rule - pricelist, pricelist_item, currency_ratio, discount_ratio = self._setup_pricelist() - - res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) - self.assertEqual(res['product_template_id'], self.computer.id) - self.assertEqual(res['product_id'], computer_variant.id) - self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)") - self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio) - self.assertEqual(res['list_price'], 2222 * currency_ratio) - self.assertEqual(res['price_extra'], 222 * currency_ratio) - - # CASE: no_variant combination, it's another variant now - - self.computer_ssd_attribute_lines.write({'active': False}) - self.ssd_attribute.create_variant = 'no_variant' - self._add_ssd_attribute_line() - computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) - computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) - computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) - combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 - - computer_variant_new = self.computer._get_variant_for_combination(combination) - self.assertTrue(computer_variant_new) - - res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) - self.assertEqual(res['product_template_id'], self.computer.id) - self.assertEqual(res['product_id'], computer_variant_new.id) - self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To)") - self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio) - self.assertEqual(res['list_price'], 2222 * currency_ratio) - self.assertEqual(res['price_extra'], 222 * currency_ratio) - - # CASE: dynamic combination, but the variant already exists - self.computer_hdd_attribute_lines.write({'active': False}) - self.hdd_attribute.create_variant = 'dynamic' - self._add_hdd_attribute_line() - computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) - computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) - computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) - combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 - - computer_variant_new = self.computer._create_product_variant(combination) - self.assertTrue(computer_variant_new) - - res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) - self.assertEqual(res['product_template_id'], self.computer.id) - self.assertEqual(res['product_id'], computer_variant_new.id) - self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To)") - self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio) - self.assertEqual(res['list_price'], 2222 * currency_ratio) - self.assertEqual(res['price_extra'], 222 * currency_ratio) - - # CASE: dynamic combination, no variant existing - # Test invalidate_cache on product.template _create_variant_ids - self._add_keyboard_attribute() - combination += self._get_product_template_attribute_value(self.keyboard_excluded) - res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) - self.assertEqual(res['product_template_id'], self.computer.id) - self.assertEqual(res['product_id'], False) - self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To, Excluded)") - self.assertEqual(res['price'], (2222 - 5) * currency_ratio * discount_ratio) - self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio) - self.assertEqual(res['price_extra'], (222 - 5) * currency_ratio) - - # CASE: pricelist set value to 0, no variant - # Test invalidate_cache on product.pricelist write - pricelist_item.percent_price = 100 - res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) - self.assertEqual(res['product_template_id'], self.computer.id) - self.assertEqual(res['product_id'], False) - self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To, Excluded)") - self.assertEqual(res['price'], 0) - self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio) - self.assertEqual(res['price_extra'], (222 - 5) * currency_ratio) - - def test_03_get_combination_info_discount_policy(self): - computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) - computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) - computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) - combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 - - pricelist, pricelist_item, currency_ratio, discount_ratio = self._setup_pricelist() - - pricelist.discount_policy = 'with_discount' - - # CASE: no discount, setting with_discount - res = self.computer._get_combination_info(combination, add_qty=1, pricelist=pricelist) - self.assertEqual(res['price'], 2222 * currency_ratio) - self.assertEqual(res['list_price'], 2222 * currency_ratio) - self.assertEqual(res['price_extra'], 222 * currency_ratio) - self.assertEqual(res['has_discounted_price'], False) - - # CASE: discount, setting with_discount - res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) - self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio) - self.assertEqual(res['list_price'], 2222 * currency_ratio) - self.assertEqual(res['price_extra'], 222 * currency_ratio) - self.assertEqual(res['has_discounted_price'], False) - - # CASE: no discount, setting without_discount - pricelist.discount_policy = 'without_discount' - res = self.computer._get_combination_info(combination, add_qty=1, pricelist=pricelist) - self.assertEqual(res['price'], 2222 * currency_ratio) - self.assertEqual(res['list_price'], 2222 * currency_ratio) - self.assertEqual(res['price_extra'], 222 * currency_ratio) - self.assertEqual(res['has_discounted_price'], False) - - # CASE: discount, setting without_discount - res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) - self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio) - self.assertEqual(res['list_price'], 2222 * currency_ratio) - self.assertEqual(res['price_extra'], 222 * currency_ratio) - self.assertEqual(res['has_discounted_price'], True) - def test_04_create_product_variant_non_dynamic(self): - """The goal of this test is to make sure the create_product_variant does + """The goal of this test is to make sure the _create_product_variant does not create variant if the type is not dynamic. It can however return a variant if it already exists.""" computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) @@ -365,7 +220,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueCommon): self.assertEqual(self.computer._create_product_variant(combination), Product) def test_05_create_product_variant_dynamic(self): - """The goal of this test is to make sure the create_product_variant does + """The goal of this test is to make sure the _create_product_variant does work with dynamic. If the combination is possible, it should create it. If it's not possible, it should not create it.""" self.computer_hdd_attribute_lines.write({'active': False}) diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index cb2adc4c829..f602277f72f 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -334,7 +334,8 @@ class WebsiteSale(http.Controller): request.session['website_sale_pricelist_time'] = now request.session['website_sale_current_pl'] = pricelist.id - request.update_context(pricelist=pricelist.id, partner=request.env.user.partner_id) + partner_sudo = request.env.user.partner_id + request.update_context(pricelist=pricelist.id, partner=partner_sudo) filter_by_price_enabled = website.is_view_active('website_sale.filter_products_price') if filter_by_price_enabled: @@ -440,7 +441,10 @@ class WebsiteSale(http.Controller): layout_mode = 'grid' request.session['website_sale_shop_layout_mode'] = layout_mode - products_prices = lazy(lambda: products._get_sales_prices(pricelist)) + # Try to fetch geoip based fpos or fallback on partner one + fiscal_position_id = website._get_current_fiscal_position_id(partner_sudo) + fiscal_position_sudo = website.env['account.fiscal.position'].sudo().browse(fiscal_position_id) + products_prices = lazy(lambda: products._get_sales_prices(pricelist, fiscal_position_sudo)) values = { 'search': fuzzy_search_term or search, @@ -539,8 +543,7 @@ class WebsiteSale(http.Controller): combination = request.env['product.template.attribute.value'].browse(combination_ids) product_product = product_template._get_variant_for_combination(combination) if not product_product: - product_product = request.env['product.product'].browse( - product_template.create_product_variant(combination_ids)) + product_product = product_template._create_product_variant(combination) if product_template.has_configurable_attributes and product_product: product_product.write({ 'product_variant_image_ids': image_create_data @@ -1881,15 +1884,15 @@ class CustomerPortal(sale_portal.CustomerPortal): return {} @http.route('/my/orders/reorder_modal_content', type='json', auth='public', website=True) - def _get_saleorder_reorder_content_modal(self, order_id, access_token): + def my_orders_reorder_modal_content(self, order_id, access_token): try: sale_order = self._document_check_access('sale.order', order_id, access_token=access_token) except (AccessError, MissingError): return request.redirect('/my') - pricelist = request.env['website'].get_current_website().get_current_pricelist() + currency = request.env['website'].get_current_website().currency_id result = { - 'currency': pricelist.currency_id.id, + 'currency': currency.id, 'products': [], } for line in sale_order.order_line: @@ -1921,8 +1924,9 @@ class CustomerPortal(sale_portal.CustomerPortal): 'has_image': bool(line.product_id.image_128), } if res['add_to_cart_allowed']: - res['combinationInfo'] = line.product_id.product_tmpl_id.with_context(**self._sale_reorder_get_line_context())\ - ._get_combination_info(combination, res['product_id'], res['qty'], pricelist) + res['combinationInfo'] = line.product_id.product_tmpl_id.with_context( + **self._sale_reorder_get_line_context() + )._get_combination_info(combination, res['product_id'], res['qty']) else: res['combinationInfo'] = {} result['products'].append(res) diff --git a/addons/website_sale/controllers/variant.py b/addons/website_sale/controllers/variant.py index 9801d73bfe9..6ae4d6cf2db 100644 --- a/addons/website_sale/controllers/variant.py +++ b/addons/website_sale/controllers/variant.py @@ -7,41 +7,39 @@ from odoo.http import request, route, Controller class WebsiteSaleVariantController(Controller): - @route('/sale/get_combination_info_website', type='json', auth="public", methods=['POST'], website=True) - def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **kw): - """Old product configurator logic, only used by frontend configurator, will be deprecated soon""" - combination = request.env['product.template.attribute.value'].browse(combination) - pricelist = request.website.get_current_pricelist() - cids = request.httprequest.cookies.get('cids', str(request.env.user.company_id.id)) - allowed_company_ids = [int(cid) for cid in cids.split(',')] - ProductTemplate = request.env['product.template'].with_context(allowed_company_ids=allowed_company_ids) - if 'context' in kw: - ProductTemplate = ProductTemplate.with_context(**kw.get('context')) - product_template = ProductTemplate.browse(int(product_template_id)) - combination_info = product_template._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist) - if 'parent_combination' in kw: - parent_combination = request.env['product.template.attribute.value'].browse(kw.get('parent_combination')) - if not combination.exists() and product_id: - product = request.env['product.product'].browse(int(product_id)) - if product.exists(): - combination = product.product_template_attribute_value_ids - combination_info.update({ - 'is_combination_possible': product_template._is_combination_possible(combination=combination, parent_combination=parent_combination), - 'parent_exclusions': product_template._get_parent_attribute_exclusions(parent_combination=parent_combination) - }) + @route('/website_sale/get_combination_info', type='json', auth='public', methods=['POST'], website=True) + def get_combination_info_website( + self, product_template_id, product_id, combination, add_qty, parent_combination=None, + **kwargs + ): + product_template = request.env['product.template'].browse( + product_template_id and int(product_template_id)) - if request.website.google_analytics_key: - combination_info['product_tracking_info'] = request.env['product.template'].get_google_analytics_data(combination_info) + combination_info = product_template._get_combination_info( + combination=request.env['product.template.attribute.value'].browse(combination), + product_id=product_id and int(product_id), + add_qty=add_qty and float(add_qty) or 1.0, + parent_combination=request.env['product.template.attribute.value'].browse(parent_combination), + ) + + # Pop data only computed to ease server-side computations. + for key in ('product_taxes', 'taxes', 'currency', 'date'): + combination_info.pop(key) if request.website.product_page_image_width != 'none' and not request.env.context.get('website_sale_no_images', False): - combination_info['carousel'] = request.env['ir.ui.view']._render_template('website_sale.shop_product_images', values={ - 'product': request.env['product.template'].browse(combination_info['product_template_id']), - 'product_variant': request.env['product.product'].browse(combination_info['product_id']), - 'website': request.env['website'].get_current_website(), - }) + combination_info['carousel'] = request.env['ir.ui.view']._render_template( + 'website_sale.shop_product_images', + values={ + 'product': product_template, + 'product_variant': request.env['product.product'].browse(combination_info['product_id']), + 'website': request.env['website'].get_current_website(), + }, + ) return combination_info - @route('/sale/create_product_variant', type='json', auth="public", methods=['POST']) + @route('/sale/create_product_variant', type='json', auth='public', methods=['POST']) def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs): """Old product configurator logic, only used by frontend configurator, will be deprecated soon""" - return request.env['product.template'].browse(int(product_template_id)).create_product_variant(json.loads(product_template_attribute_value_ids)) + return request.env['product.template'].browse( + int(product_template_id) + ).create_product_variant(json.loads(product_template_attribute_value_ids)) diff --git a/addons/website_sale/models/__init__.py b/addons/website_sale/models/__init__.py index 919281835fe..44a62201a14 100644 --- a/addons/website_sale/models/__init__.py +++ b/addons/website_sale/models/__init__.py @@ -15,6 +15,7 @@ from . import product_public_category from . import product_ribbon from . import product_tag from . import product_template +from . import product_template_attribute_value from . import res_company from . import res_config_settings from . import res_country diff --git a/addons/website_sale/models/product_product.py b/addons/website_sale/models/product_product.py index 36036a6460f..76c1dabe50a 100644 --- a/addons/website_sale/models/product_product.py +++ b/addons/website_sale/models/product_product.py @@ -88,6 +88,15 @@ class Product(models.Model): # the template extra images here return variant_images + self.product_tmpl_id._get_images()[1:] + def _get_combination_info_variant(self, **kwargs): + """Return the variant info based on its combination. + See `_get_combination_info` for more information. + """ + self.ensure_one() + return self.product_tmpl_id._get_combination_info( + combination=self.product_template_attribute_value_ids, + product_id=self.id, + **kwargs) def _website_show_quick_add(self): website = self.env['website'].get_current_website() diff --git a/addons/website_sale/models/product_template.py b/addons/website_sale/models/product_template.py index 0098a9aa9d1..8c9fd3e6004 100644 --- a/addons/website_sale/models/product_template.py +++ b/addons/website_sale/models/product_template.py @@ -2,9 +2,10 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging -from odoo import api, fields, models, _ +from odoo import api, fields, models from odoo.addons.http_routing.models.ir_http import slug, unslug from odoo.addons.website.models import ir_http +from odoo.tools import float_is_zero from odoo.tools.translate import html_translate from odoo.osv import expression @@ -166,15 +167,15 @@ class ProductTemplate(models.Model): return self._get_possible_variants(parent_combination).sorted(_sort_key_variant) - def _get_sales_prices(self, pricelist): + def _get_sales_prices(self, pricelist, fiscal_position): + if not self: + return {} + pricelist and pricelist.ensure_one() partner_sudo = self.env.user.partner_id pricelist = pricelist or self.env['product.pricelist'] currency = pricelist.currency_id or self.env.company.currency_id - - # Try to fetch geoip based fpos or fallback on partner one - fpos_id = self.env['website']._get_current_fiscal_position_id(partner_sudo) - fiscal_position = self.env['account.fiscal.position'].sudo().browse(fpos_id) + date = fields.Date.context_today(self) sales_prices = pricelist._get_products_price(self, 1.0) show_discount = pricelist and pricelist.discount_policy == 'without_discount' @@ -207,7 +208,7 @@ class ProductTemplate(models.Model): base_price, pricelist.currency_id, self.env.company, - fields.Datetime.now(), + date, round=False ) @@ -233,101 +234,300 @@ class ProductTemplate(models.Model): return res - def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False): - """Override for website, where we want to: - - take the website pricelist if no pricelist is set and one is available - - apply the b2b/b2c setting to the result + def _can_be_added_to_cart(self): + """ + Pre-check to `_is_add_to_cart_possible` to know if product can be sold. + """ + return self.sale_ok - This will work when adding website_id to the context, which is done - automatically when called from routes with website=True. + def _is_add_to_cart_possible(self, parent_combination=None): + """ + It's possible to add to cart (potentially after configuration) if + there is at least one possible combination. + + :param parent_combination: the combination from which `self` is an + optional or accessory product. + :type parent_combination: recordset `product.template.attribute.value` + + :return: True if it's possible to add to cart, else False + :rtype: bool + """ + self.ensure_one() + if not self.active or not self._can_be_added_to_cart(): + # for performance: avoid calling `_get_possible_combinations` + return False + return next(self._get_possible_combinations(parent_combination), False) is not False + + def _get_combination_info( + self, combination=False, product_id=False, add_qty=1.0, + parent_combination=False, only_template=False, + ): + """ Return info about a given combination. + + Note: this method does not take into account whether the combination is + actually possible. + + :param combination: recordset of `product.template.attribute.value` + + :param int product_id: `product.product` id. If no `combination` + is set, the method will try to load the variant `product_id` if + it exists instead of finding a variant based on the combination. + + If there is no combination, that means we definitely want a + variant and not something that will have no_variant set. + + :param float add_qty: the quantity for which to get the info, + indeed some pricelist rules might depend on it. + + :param parent_combination: if no combination and no product_id are + given, it will try to find the first possible combination, taking + into account parent_combination (if set) for the exclusion rules. + + :param only_template: boolean, if set to True, get the info for the + template only: ignore combination and don't try to find variant + + :return: dict with product/combination info: + + - product_id: the variant id matching the combination (if it exists) + + - product_template_id: the current template id + + - display_name: the name of the combination + + - price: the computed price of the combination, take the catalog + price if no pricelist is given + + - price_extra: the computed extra price of the combination + + - list_price: the catalog price of the combination, but this is + not the "real" list_price, it has price_extra included (so + it's actually more closely related to `lst_price`), and it + is converted to the pricelist currency (if given) + + - has_discounted_price: True if the pricelist discount policy says + the price does not include the discount and there is actually a + discount applied (price < list_price), else False """ self.ensure_one() - current_website = self.env['website'].get_current_website() + combination = combination or self.env['product.template.attribute.value'] + parent_combination = parent_combination or self.env['product.template.attribute.value'] + website = self.env['website'].get_current_website().with_context(self.env.context) - if self.env.context.get('website_id'): - if not pricelist: - pricelist = current_website.get_current_pricelist() + if not product_id and not combination and not only_template: + combination = self._get_first_possible_combination(parent_combination) - combination_info = super(ProductTemplate, self)._get_combination_info( - combination=combination, product_id=product_id, add_qty=add_qty, pricelist=pricelist, - parent_combination=parent_combination, only_template=only_template) + if only_template: + product = self.env['product.product'] + elif product_id: + product = self.env['product.product'].browse(product_id) + if (combination - product.product_template_attribute_value_ids): + # If the combination is not fully represented in the given product + # make sure to fetch the right product for the given combination + product = self._get_variant_for_combination(combination) + else: + product = self._get_variant_for_combination(combination) - product = self.env['product.product'].browse(combination_info['product_id']) + product_or_template = product or self + combination = combination or product.product_template_attribute_value_ids - if current_website.is_view_active('website_sale.product_tags'): - combination_info['product_tags'] = self.env['ir.ui.view']._render_template( - 'website_sale.product_tags', values={ - 'all_product_tags': product.all_product_tag_ids.filtered('visible_on_ecommerce') - } + display_name = product_or_template.display_name + if not product: + combination_name = combination._get_combination_name() + if combination_name: + display_name = f"{display_name} ({combination_name})" + + price_context = product_or_template._get_product_price_context(combination) + product_or_template = product_or_template.with_context(**price_context) + + combination_info = { + 'product_id': product.id, + 'product_template_id': self.id, + 'display_name': display_name, + 'display_image': bool(product_or_template.image_128), + 'is_combination_possible': self._is_combination_possible(combination=combination, parent_combination=parent_combination), + 'parent_exclusions': self._get_parent_attribute_exclusions(parent_combination=parent_combination), + + **self._get_additionnal_combination_info( + product_or_template=product_or_template, + quantity=add_qty or 1.0, + date=fields.Date.context_today(self), + website=website, ) + } - if self.env.context.get('website_id'): - product = product or self - partner = self.env.user.partner_id - company_id = current_website.company_id - currency = current_website.currency_id - - fpos_id = self.env['website'].sudo()._get_current_fiscal_position_id(partner) - fiscal_position = self.env['account.fiscal.position'].sudo().browse(fpos_id) - product_taxes = product.sudo().taxes_id.filtered_domain(self.env['account.tax']._check_company_domain(company_id)) - taxes = fiscal_position.map_tax(product_taxes) - - price = self._price_with_tax_computed( - combination_info['price'], product_taxes, taxes, company_id, currency, product, - partner - ) - - if pricelist.discount_policy == 'without_discount': - list_price = self._price_with_tax_computed( - combination_info['list_price'], product_taxes, taxes, company_id, currency, - product, partner - ) - else: - list_price = price - price_extra = self._price_with_tax_computed( - combination_info['price_extra'], product_taxes, taxes, company_id, currency, - product, partner - ) - base_unit_price = product._get_base_unit_price(list_price) - if currency != product.currency_id: - base_unit_price = product.currency_id._convert( - base_unit_price, - currency, - company_id, - fields.Date.today()) - has_discounted_price = currency.compare_amounts(list_price, price) == 1 - prevent_zero_price_sale = not price and current_website.prevent_zero_price_sale - - compare_list_price = self.compare_list_price - if self.currency_id != pricelist.currency_id: - compare_list_price = self.currency_id._convert(self.compare_list_price, pricelist.currency_id, self.env.company, - fields.Datetime.now(), round=False) - - combination_info.update( - base_unit_name=product.base_unit_name, - base_unit_price=product.base_unit_count and list_price / product.base_unit_count, - price=price, - list_price=list_price, - price_extra=price_extra, - has_discounted_price=has_discounted_price, - prevent_zero_price_sale=prevent_zero_price_sale, - compare_list_price=compare_list_price, + if website.google_analytics_key: + combination_info['product_tracking_info'] = self._get_google_analytics_data( + product, + combination_info, ) return combination_info - def _price_with_tax_computed( - self, price, product_taxes, taxes, company_id, currency, product, partner + def _get_additionnal_combination_info(self, product_or_template, quantity, date, website): + """Computes additional combination info, based on given parameters + + :param product_or_template: `product.product` or `product.template` record + as variant values must take precedence over template values (when we have a variant) + :param float quantity: + :param date date: today's date, avoids useless calls to today/context_today and harmonize + behavior + :param website: `website` record holding the current website of the request (if any), + or the contextual website (tests, ...) + :returns: additional product/template information + :rtype: dict + """ + pricelist = website.pricelist_id + currency = website.currency_id + + compare_list_price = product_or_template.compare_list_price + list_price = product_or_template._price_compute('list_price')[product_or_template.id] + price_extra = product_or_template._get_attributes_extra_price() + if product_or_template.currency_id != currency: + price_extra = self.currency_id._convert( + from_amount=price_extra, + to_currency=currency, + company=self.env.company, + date=date, + ) + list_price = self.currency_id._convert( + from_amount=list_price, + to_currency=currency, + company=self.env.company, + date=date, + ) + compare_list_price = product_or_template.currency_id._convert( + from_amount=compare_list_price, + to_currency=self.currency_id, + company=self.env.company, + date=date, + round=False) + + # Pricelist price doesn't have to be converted + pricelist_price = pricelist._get_product_price( + product=product_or_template, + quantity=quantity, + target_currency=currency, + ) + + if pricelist.discount_policy == 'without_discount': + has_discounted_price = currency.compare_amounts(list_price, pricelist_price) == 1 + else: + has_discounted_price = False + + combination_info = { + 'price_extra': price_extra, + 'price': pricelist_price, + 'list_price': list_price, + 'has_discounted_price': has_discounted_price, + 'compare_list_price': compare_list_price, + } + + # Apply taxes + partner_sudo = self.env.user.partner_id + fpos_id = website.sudo()._get_current_fiscal_position_id(partner_sudo) + fiscal_position = self.env['account.fiscal.position'].sudo().browse(fpos_id) + + + product_taxes = product_or_template.sudo().taxes_id.filtered( + lambda t: t.company_id == self.env.company) + taxes = self.env['account.tax'] + if product_taxes: + taxes = fiscal_position.map_tax(product_taxes) + # We do not apply taxes on the compare_list_price value because it's meant to be + # a strict value displayed as is. + for price_key in ('price', 'list_price', 'price_extra'): + combination_info[price_key] = self._apply_taxes_to_price( + combination_info[price_key], + currency, + product_taxes, + taxes, + product_or_template, + ) + + combination_info.update({ + 'prevent_zero_price_sale': website.prevent_zero_price_sale and float_is_zero( + combination_info['price'], + precision_rounding=currency.rounding, + ), + + 'base_unit_name': product_or_template.base_unit_name, + 'base_unit_price': product_or_template._get_base_unit_price(combination_info['price']), + + # additional info to simplify overrides + 'currency': currency, # displayed currency + 'date': date, + 'product_taxes': product_taxes, # taxes before fpos mapping + 'taxes': taxes, # taxes after fpos mapping + }) + + if pricelist.discount_policy != 'without_discount': + # Leftover from before cleanup, different behavior between ecommerce & backend configurator + # probably to keep product sales price hidden from customers ? + combination_info['list_price'] = combination_info['price'] + + if website.is_view_active('website_sale.product_tags') and product_or_template.is_product_variant: + combination_info['product_tags'] = self.env['ir.ui.view']._render_template( + 'website_sale.product_tags', values={ + 'all_product_tags': product_or_template.all_product_tag_ids.filtered('visible_on_ecommerce') + } + ) + + return combination_info + + @api.model + def _apply_taxes_to_price( + self, price, currency, product_taxes, taxes, product_or_template, ): + # Ideally, we should use _get_tax_included_unit_price + # but it doesn't allow to request tax-excluded amount. website = self.env['website'].get_current_website() price = self.env['account.tax']._fix_tax_included_price_company( - price, product_taxes, taxes, company_id + price, product_taxes, taxes, self.env.company, ) show_tax = website.show_line_subtotals_tax_selection tax_display = 'total_excluded' if show_tax == 'tax_excluded' else 'total_included' + # The list_price is always the price of one. - return taxes.compute_all(price, currency, 1, product, partner)[tax_display] + return taxes.compute_all( + price, currency, 1, product_or_template, self.env.user.partner_id + )[tax_display] + + def create_product_variant(self, product_template_attribute_value_ids): + """ Create if necessary and possible and return the id of the product + variant matching the given combination for this template. + + Note AWA: Known "exploit" issues with this method: + + - This method could be used by an unauthenticated user to generate a + lot of useless variants. Unfortunately, after discussing the + matter with ODO, there's no easy and user-friendly way to block + that behavior. + + We would have to use captcha/server actions to clean/... that + are all not user-friendly/overkill mechanisms. + + - This method could be used to try to guess what product variant ids + are created in the system and what product template ids are + configured as "dynamic", but that does not seem like a big deal. + + The error messages are identical on purpose to avoid giving too much + information to a potential attacker: + - returning 0 when failing + - returning the variant id whether it already existed or not + + :param product_template_attribute_value_ids: the combination for which + to get or create variant + :type product_template_attribute_value_ids: list of id + of `product.template.attribute.value` + + :return: id of the product variant matching the combination or 0 + :rtype: int + """ + combination = self.env['product.template.attribute.value'].browse( + product_template_attribute_value_ids) + + return self._create_product_variant(combination, log_warning=True).id or 0 def _get_image_holder(self): """Returns the holder of the image to use as default representation. @@ -349,14 +549,6 @@ class ProductTemplate(models.Model): return 'image_512' return 'image_1024' - - def _get_current_company_fallback(self, **kwargs): - """Override: if a website is set on the product or given, fallback to - the company of the website. Otherwise use the one from parent method.""" - res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs) - website = self.website_id or kwargs.get('website') - return website and website.company_id or res - def _init_column(self, column_name): # to avoid generating a single default website_sequence when installing the module, # we need to set the default row by row for this column @@ -418,7 +610,7 @@ class ProductTemplate(models.Model): return self.set_sequence_bottom() def _default_website_meta(self): - res = super(ProductTemplate, self)._default_website_meta() + res = super()._default_website_meta() res['default_opengraph']['og:description'] = res['default_twitter']['twitter:description'] = self.description_sale res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name res['default_opengraph']['og:image'] = res['default_twitter']['twitter:image'] = self.env['website'].image_url(self, 'image_1024') @@ -426,7 +618,7 @@ class ProductTemplate(models.Model): return res def _compute_website_url(self): - super(ProductTemplate, self)._compute_website_url() + super()._compute_website_url() for product in self: if product.id: product.website_url = "/shop/%s" % slug(product) @@ -441,7 +633,7 @@ class ProductTemplate(models.Model): def _rating_domain(self): """ Only take the published rating into account to compute avg and count """ - domain = super(ProductTemplate, self)._rating_domain() + domain = super()._rating_domain() return expression.AND([domain, [('is_internal', '=', False)]]) def _get_images(self): @@ -550,7 +742,7 @@ class ProductTemplate(models.Model): def _search_render_results_prices(self, mapping, combination_info): monetary_options = {'display_currency': mapping['detail']['display_currency']} - if combination_info['prevent_zero_price_sale']: + if combination_info.get('prevent_zero_price_sale'): website = self.env['website'].get_current_website() price = website.prevent_zero_price_sale_text else: @@ -568,26 +760,23 @@ class ProductTemplate(models.Model): return price, list_price if combination_info['has_discounted_price'] else None - @api.model - def get_google_analytics_data(self, combination): - product = self.env['product.product'].browse(combination['product_id']) + def _get_google_analytics_data(self, product, combination_info): + self.ensure_one() return { 'item_id': product.barcode or product.id, - 'item_name': combination['display_name'], - 'item_category': product.categ_id.name or '-', - 'currency': product.currency_id.name, - 'price': combination['list_price'], + 'item_name': combination_info['display_name'], + 'item_category': self.categ_id.name, + 'currency': combination_info['currency'].name, + 'price': combination_info['list_price'], } def _get_contextual_pricelist(self): - """ Override to fallback on website current pricelist - """ + """ Override to fallback on website current pricelist """ pricelist = super()._get_contextual_pricelist() - if pricelist: - return pricelist - website = ir_http.get_request_website() - if website: - return website.get_current_pricelist() + if not pricelist: + website = ir_http.get_request_website() + if website: + return website.pricelist_id return pricelist def _website_show_quick_add(self): diff --git a/addons/website_sale/models/product_template_attribute_value.py b/addons/website_sale/models/product_template_attribute_value.py new file mode 100644 index 00000000000..480421cb8bc --- /dev/null +++ b/addons/website_sale/models/product_template_attribute_value.py @@ -0,0 +1,38 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class ProductTemplateAttributeValue(models.Model): + _inherit = 'product.template.attribute.value' + + def _get_extra_price(self, combination_info): + self.ensure_one() + if not self.price_extra: + return 0.0 + + price_extra = self.price_extra + if not price_extra: + return price_extra + + product_template = self.product_tmpl_id + currency = combination_info['currency'] + if currency != product_template.currency_id: + price_extra = self.currency_id._convert( + from_amount=price_extra, + to_currency=currency, + company=self.env.company, + date=combination_info['date'], + ) + + product_taxes = combination_info['product_taxes'] + if product_taxes: + price_extra = self.env['product.template']._apply_taxes_to_price( + price_extra, + combination_info['currency'], + product_taxes, + combination_info['taxes'], + self.product_tmpl_id, + ) + + return price_extra diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py index 910abb480a4..58244ad24fa 100644 --- a/addons/website_sale/models/website.py +++ b/addons/website_sale/models/website.py @@ -1,15 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import logging - from odoo import api, fields, models, tools, SUPERUSER_ID, _ - from odoo.http import request from odoo.osv import expression -from odoo.addons.http_routing.models.ir_http import url_for -_logger = logging.getLogger(__name__) +from odoo.addons.http_routing.models.ir_http import url_for class Website(models.Model): @@ -471,17 +467,18 @@ class Website(models.Model): return self.get_current_pricelist().id \ or partner_sudo.property_product_pricelist.id - @api.model def _get_current_fiscal_position_id(self, partner_sudo): AccountFiscalPosition = self.env['account.fiscal.position'].sudo() fpos = AccountFiscalPosition # If the current user is the website public user, the fiscal position # is computed according to geolocation. - if request and request.website.partner_id.id == partner_sudo.id: - if request.geoip.country_code: - country_id = self.env['res.country'].search([('code', '=', request.geoip.country_code)], limit=1).id - fpos = AccountFiscalPosition._get_fpos_by_region(country_id) + if request and request.geoip.country_code and self.partner_id.id == partner_sudo.id: + country = self.env['res.country'].search( + [('code', '=', request.geoip.country_code)], + limit=1, + ).id + fpos = AccountFiscalPosition._get_fpos_by_region(country.id) if not fpos: fpos = AccountFiscalPosition._get_fiscal_position(partner_sudo) @@ -568,6 +565,7 @@ class Website(models.Model): return self.is_view_active('website_sale.address_b2b') + class WebsiteSaleExtraField(models.Model): _name = 'website.sale.extra.field' _description = 'E-Commerce Extra Info Shown on product page' diff --git a/addons/website_sale/static/src/js/sale_variant_mixin.js b/addons/website_sale/static/src/js/sale_variant_mixin.js index 089219860bc..16c4b58fa4c 100644 --- a/addons/website_sale/static/src/js/sale_variant_mixin.js +++ b/addons/website_sale/static/src/js/sale_variant_mixin.js @@ -70,13 +70,13 @@ var VariantMixin = { const $currentOptionalProduct = $(optionalProduct); const childCombination = this.getSelectedVariantValues($currentOptionalProduct); const productTemplateId = parseInt($currentOptionalProduct.find('.product_template_id').val()); - ajax.jsonRpc(this._getUri('/sale/get_combination_info'), 'call', { + ajax.jsonRpc('/website_sale/get_combination_info', 'call', { 'product_template_id': productTemplateId, 'product_id': this._getProductId($currentOptionalProduct), 'combination': childCombination, 'add_qty': parseInt($currentOptionalProduct.find('input[name="add_qty"]').val()), - 'pricelist_id': this.pricelistId || false, 'parent_combination': combination, + 'context': this.context, ...this._getOptionalCombinationInfoParam($currentOptionalProduct), }).then((combinationData) => { this._onChangeCombination(ev, $currentOptionalProduct, combinationData); @@ -89,13 +89,13 @@ var VariantMixin = { ); } - return ajax.jsonRpc(this._getUri('/sale/get_combination_info'), 'call', { + return ajax.jsonRpc('/website_sale/get_combination_info', 'call', { 'product_template_id': parseInt($parent.find('.product_template_id').val()), 'product_id': this._getProductId($parent), 'combination': combination, 'add_qty': parseInt($parent.find('input[name="add_qty"]').val()), - 'pricelist_id': this.pricelistId || false, 'parent_combination': parentCombination, + 'context': this.context, ...this._getOptionalCombinationInfoParam($parent), }).then((combinationData) => { this._onChangeCombination(ev, $parent, combinationData); diff --git a/addons/website_sale/static/src/js/variant_mixin.js b/addons/website_sale/static/src/js/variant_mixin.js index 136d35cbb2b..68335d78509 100644 --- a/addons/website_sale/static/src/js/variant_mixin.js +++ b/addons/website_sale/static/src/js/variant_mixin.js @@ -2,20 +2,6 @@ import VariantMixin from "@website_sale/js/sale_variant_mixin"; -/** - * Website behavior is slightly different from backend so we append - * "_website" to URLs to lead to a different route - * - * @private - * @param {string} uri The uri to adapt - */ -VariantMixin._getUri = function (uri) { - if (this.isWebsite) { - return uri + '_website'; - } else { - return uri; - } -}; const originalOnChangeCombination = VariantMixin._onChangeCombination; VariantMixin._onChangeCombination = function (ev, $parent, combination) { const $pricePerUom = $parent.find(".o_base_unit_price:first .oe_currency_value"); diff --git a/addons/website_sale/static/src/js/website_sale_reorder.js b/addons/website_sale/static/src/js/website_sale_reorder.js index f22ed99d5f0..e6d676e5016 100644 --- a/addons/website_sale/static/src/js/website_sale_reorder.js +++ b/addons/website_sale/static/src/js/website_sale_reorder.js @@ -81,12 +81,11 @@ export class ReorderDialog extends Component { } async loadProductCombinationInfo(product) { - product.combinationInfo = await this.rpc("/sale/get_combination_info_website", { + product.combinationInfo = await this.rpc("/website_sale/get_combination_info", { product_template_id: product.product_template_id, product_id: product.product_id, combination: product.combination, add_qty: product.qty, - pricelist_id: false, context: { website_sale_no_images: true, }, diff --git a/addons/website_sale/tests/test_website_sale_pricelist.py b/addons/website_sale/tests/test_website_sale_pricelist.py index 3865ecb7f25..573254dae8d 100644 --- a/addons/website_sale/tests/test_website_sale_pricelist.py +++ b/addons/website_sale/tests/test_website_sale_pricelist.py @@ -293,7 +293,7 @@ class TestWebsitePriceList(TransactionCase): 'taxes_id': tax, }) - prices = product._get_sales_prices(self.list_christmas) + prices = product._get_sales_prices(self.list_christmas, self.env['account.fiscal.position']) self.assertFalse('base_price' in prices[product.id]) def test_pricelist_item_based_on_cost_for_templates(self): @@ -317,7 +317,8 @@ class TestWebsitePriceList(TransactionCase): 'name': 'Product Template', 'list_price': 10.0, 'standard_price': 5.0 }) self.assertEqual(product_template.standard_price, 5) - price = product_template._get_sales_prices(pricelist)[product_template.id]['price_reduce'] + price = product_template._get_sales_prices( + pricelist, self.env['account.fiscal.position'])[product_template.id]['price_reduce'] msg = "Template has no variants, the price should be computed based on the template's cost." self.assertEqual(price, 4.5, msg) @@ -328,12 +329,14 @@ class TestWebsitePriceList(TransactionCase): self.assertEqual(product_template.standard_price, 0, msg) self.assertEqual(product_template.product_variant_ids[0].standard_price, 0) - price = product_template._get_sales_prices(pricelist)[product_template.id]['price_reduce'] + price = product_template._get_sales_prices( + pricelist, self.env['account.fiscal.position'])[product_template.id]['price_reduce'] msg = "Template has variants, the price should be computed based on the 1st variant's cost." self.assertEqual(price, 0, msg) product_template.product_variant_ids[0].standard_price = 20 - price = product_template._get_sales_prices(pricelist)[product_template.id]['price_reduce'] + price = product_template._get_sales_prices( + pricelist, self.env['account.fiscal.position'])[product_template.id]['price_reduce'] self.assertEqual(price, 18, msg) def simulate_frontend_context(self, website_id=1): diff --git a/addons/website_sale/tests/test_website_sale_product.py b/addons/website_sale/tests/test_website_sale_product.py index 6b39fd693a8..cfd4dc3c8cd 100644 --- a/addons/website_sale/tests/test_website_sale_product.py +++ b/addons/website_sale/tests/test_website_sale_product.py @@ -71,8 +71,8 @@ class WebsiteSaleProductTests(TestSaleProductAttributeValueCommon): 'product_tmpl_id': product_tmpl.id, }) current_website = self.env['website'].get_current_website() - pricelist = current_website.get_current_pricelist() + pricelist = current_website._get_current_pricelist() pricelist.item_ids = [(6, 0, [pricelist_item.id])] pricelist.discount_policy = 'without_discount' - res = product_tmpl._get_sales_prices(pricelist) + res = product_tmpl._get_sales_prices(pricelist, self.env['account.fiscal.position']) self.assertEqual(res[product_tmpl.id]['base_price'], 75) diff --git a/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py b/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py index a9197ea99c3..5ff7e0efd4b 100644 --- a/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py +++ b/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.addons.sale.tests.test_sale_product_attribute_value_config import TestSaleProductAttributeValueCommon -from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo import Command from odoo.tests import tagged + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.addons.sale.tests.test_sale_product_attribute_value_config import TestSaleProductAttributeValueCommon from odoo.addons.website.tools import MockRequest @@ -20,6 +21,7 @@ class TestWebsiteSaleProductAttributeValueConfig(AccountTestInvoicingCommon, Tes def test_get_combination_info(self): # Setup pricelist: make sure the pricelist has a 10% discount + self.env['product.pricelist'].search([]).action_archive() pricelist = self.env['product.pricelist'].create({ 'name': "test_get_combination_info", 'currency_id': self.currency_data['currency'].id, @@ -51,7 +53,7 @@ class TestWebsiteSaleProductAttributeValueConfig(AccountTestInvoicingCommon, Tes currency_ratio = 2 # CASE: B2B setting (default) - combination_info = product_template._get_combination_info(pricelist=pricelist) + combination_info = product_template._get_combination_info() self.assertEqual(combination_info['price'], 2222 * discount_rate * currency_ratio) self.assertEqual(combination_info['list_price'], 2222 * discount_rate * currency_ratio) self.assertEqual(combination_info['price_extra'], 222 * currency_ratio) @@ -60,7 +62,7 @@ class TestWebsiteSaleProductAttributeValueConfig(AccountTestInvoicingCommon, Tes # CASE: B2C setting website.show_line_subtotals_tax_selection = 'tax_included' - combination_info = product_template._get_combination_info(pricelist=pricelist) + combination_info = product_template._get_combination_info() self.assertEqual(combination_info['price'], 2222 * discount_rate * currency_ratio * tax_ratio) self.assertEqual(combination_info['list_price'], 2222 * discount_rate * currency_ratio * tax_ratio) self.assertEqual(combination_info['price_extra'], round(222 * currency_ratio * tax_ratio, 2)) @@ -69,7 +71,7 @@ class TestWebsiteSaleProductAttributeValueConfig(AccountTestInvoicingCommon, Tes # CASE: pricelist 'without_discount' pricelist.discount_policy = 'without_discount' - combination_info = product_template._get_combination_info(pricelist=pricelist) + combination_info = product_template._get_combination_info() self.assertEqual(combination_info['price'], pricelist.currency_id.round(2222 * discount_rate * currency_ratio * tax_ratio), 0) self.assertEqual(combination_info['list_price'], pricelist.currency_id.round(2222 * currency_ratio * tax_ratio), 0) self.assertEqual(combination_info['price_extra'], pricelist.currency_id.round(222 * currency_ratio * tax_ratio), 0) @@ -85,6 +87,7 @@ class TestWebsiteSaleProductAttributeValueConfig(AccountTestInvoicingCommon, Tes }) # Setup pricelist: make sure the pricelist has a 10% discount + self.env['product.pricelist'].search([]).action_archive() pricelist = self.env['product.pricelist'].create({ 'name': "test_get_combination_info", 'company_id': self.env.company.id, @@ -118,7 +121,7 @@ class TestWebsiteSaleProductAttributeValueConfig(AccountTestInvoicingCommon, Tes # Enable tax included website.show_line_subtotals_tax_selection = 'tax_included' - combination_info = product._get_combination_info(pricelist=pricelist) + combination_info = product._get_combination_info() self.assertEqual(combination_info['price'], 575, "500$ + 15% tax") self.assertEqual(combination_info['list_price'], 575, "500$ + 15% tax (2)") self.assertEqual(combination_info['price_extra'], 230, "200$ + 15% tax") @@ -138,7 +141,7 @@ class TestWebsiteSaleProductAttributeValueConfig(AccountTestInvoicingCommon, Tes # Now with fiscal position, taxes should be mapped self.env.user.partner_id.country_id = us_country - combination_info = product._get_combination_info(pricelist=pricelist) + combination_info = product._get_combination_info() self.assertEqual(combination_info['price'], 500, "500% + 0% tax (mapped from fp 15% -> 0%)") self.assertEqual(combination_info['list_price'], 500, "500% + 0% tax (mapped from fp 15% -> 0%)") self.assertEqual(combination_info['price_extra'], 200, "200% + 0% tax (mapped from fp 15% -> 0%)") @@ -148,14 +151,14 @@ class TestWebsiteSaleProductAttributeValueConfig(AccountTestInvoicingCommon, Tes # Reset / Safety check self.env.user.partner_id.country_id = None - combination_info = product._get_combination_info(pricelist=pricelist) + combination_info = product._get_combination_info() self.assertEqual(combination_info['price'], 500, "434.78$ + 15% tax") self.assertEqual(combination_info['list_price'], 500, "434.78$ + 15% tax (2)") self.assertEqual(combination_info['price_extra'], 200, "173.91$ + 15% tax") # Now with fiscal position, taxes should be mapped self.env.user.partner_id.country_id = us_country.id - combination_info = product._get_combination_info(pricelist=pricelist) + combination_info = product._get_combination_info() self.assertEqual(round(combination_info['price'], 2), 434.78, "434.78$ + 0% tax (mapped from fp 15% -> 0%)") self.assertEqual(round(combination_info['list_price'], 2), 434.78, "434.78$ + 0% tax (mapped from fp 15% -> 0%)") self.assertEqual(combination_info['price_extra'], 173.91, "173.91$ + 0% tax (mapped from fp 15% -> 0%)") diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 1e1b4aab943..6fe1ecbaa8b 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -356,7 +356,7 @@
-
+
@@ -948,16 +948,18 @@ - +
-
+
@@ -1259,8 +1261,11 @@ diff --git a/addons/website_sale_comparison/views/website_sale_comparison_template.xml b/addons/website_sale_comparison/views/website_sale_comparison_template.xml index 207bc6256d1..227726e7ca3 100644 --- a/addons/website_sale_comparison/views/website_sale_comparison_template.xml +++ b/addons/website_sale_comparison/views/website_sale_comparison_template.xml @@ -121,22 +121,36 @@ Price: - - + - - +
- - Contact Us + + + Contact Us + Add to Cart @@ -198,9 +212,18 @@

- - - + + +
diff --git a/addons/website_sale_product_configurator/__manifest__.py b/addons/website_sale_product_configurator/__manifest__.py index b4b83f51ee7..770f0027c4e 100644 --- a/addons/website_sale_product_configurator/__manifest__.py +++ b/addons/website_sale_product_configurator/__manifest__.py @@ -7,12 +7,10 @@ Bridge module to make the website e-commerce compatible with the product configurator """, 'category': 'Hidden', - 'version': '0.1', 'depends': ['website_sale', 'sale_product_configurator'], 'auto_install': True, 'data': [ 'views/templates.xml', - 'views/website_sale_product_configurator_templates.xml', ], 'demo': [ 'data/demo.xml', @@ -20,7 +18,6 @@ 'assets': { 'web.assets_frontend': [ ('before', 'website_sale/static/src/js/website_sale.js', 'website_sale_product_configurator/static/src/js/sale_product_configurator_modal.js'), - ('before', 'website_sale/static/src/js/website_sale.js', 'website_sale_product_configurator/static/src/js/product_configurator_modal.js'), 'website_sale/static/src/scss/product_configurator.scss', 'website_sale_product_configurator/static/src/scss/website_sale_options.scss', 'website_sale_product_configurator/static/src/js/website_sale_options.js', diff --git a/addons/website_sale_product_configurator/controllers/__init__.py b/addons/website_sale_product_configurator/controllers/__init__.py index c64e1ca4371..66be1a9cdce 100644 --- a/addons/website_sale_product_configurator/controllers/__init__.py +++ b/addons/website_sale_product_configurator/controllers/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- from . import main -from . import sale_product_configurator +from . import website_sale diff --git a/addons/website_sale_product_configurator/controllers/main.py b/addons/website_sale_product_configurator/controllers/main.py index e762d2936bc..6516e181175 100644 --- a/addons/website_sale_product_configurator/controllers/main.py +++ b/addons/website_sale_product_configurator/controllers/main.py @@ -1,101 +1,69 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import json -from odoo import http -from odoo.http import request -from odoo.addons.website_sale_product_configurator.controllers.sale_product_configurator import ProductConfiguratorController -from odoo.addons.website_sale.controllers import main +from odoo.http import Controller, request, route -class WebsiteSaleProductConfiguratorController(ProductConfiguratorController): - @http.route(['/sale_product_configurator/show_advanced_configurator_website'], type='json', auth="public", methods=['POST'], website=True) - def show_advanced_configurator_website(self, product_id, variant_values, **kw): - """Special route to use website logic in get_combination_info override. - This route is called in JS by appending _website to the base route. - """ - kw.pop('pricelist_id') +class WebsiteSaleProductConfiguratorController(Controller): + + @route( + '/sale_product_configurator/show_advanced_configurator', + type='json', auth='public', methods=['POST'], website=True, + ) + def show_advanced_configurator( + self, product_id, variant_values, add_qty=1, force_dialog=False, **kw, + ): product = request.env['product.product'].browse(int(product_id)) + product_template = product.product_tmpl_id combination = request.env['product.template.attribute.value'].browse(variant_values) - has_optional_products = product.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(combination)) - force_dialog = kw.get('force_dialog') + has_optional_products = product.optional_product_ids.filtered( + lambda p: p._is_add_to_cart_possible(combination)) - if not force_dialog and not has_optional_products and (product.product_variant_count <= 1 or variant_values): + already_configured = bool(combination) + if not force_dialog and not has_optional_products and ( + product.product_variant_count <= 1 or already_configured + ): # The modal is not shown if there are no optional products and # the main product either has no variants or is already configured return False - if variant_values: - kw["already_configured"] = True - return self.show_advanced_configurator(product_id, variant_values, request.website.pricelist_id, **kw) - @http.route(['/sale_product_configurator/optional_product_items_website'], type='json', auth="public", methods=['POST'], website=True) - def optional_product_items_website(self, product_id, **kw): - """Special route to use website logic in get_combination_info override. - This route is called in JS by appending _website to the base route. - """ - kw.pop('pricelist_id') - return self.optional_product_items(product_id, request.website.pricelist_id, **kw) + add_qty = float(add_qty) + combination_info = product_template._get_combination_info( + combination=combination, + product_id=product.id, + add_qty=add_qty, + ) - -class WebsiteSale(main.WebsiteSale): - def _prepare_product_values(self, product, category, search, **kwargs): - values = super(WebsiteSale, self)._prepare_product_values(product, category, search, **kwargs) - - values['optional_product_ids'] = [p.with_context(active_id=p.id) for p in product.optional_product_ids] - return values - - @http.route(['/shop/cart/update_option'], type='http', auth="public", methods=['POST'], website=True, multilang=False) - def cart_options_update_json(self, product_and_options, goto_shop=None, lang=None, **kwargs): - """This route is called when submitting the optional product modal. - The product without parent is the main product, the other are options. - Options need to be linked to their parents with a unique ID. - The main product is the first product in the list and the options - need to be right after their parent. - product_and_options { - 'product_id', - 'product_template_id', - 'quantity', - 'parent_unique_id', - 'unique_id', - 'product_custom_attribute_values', - 'no_variant_attribute_values' + return request.env['ir.ui.view']._render_template( + 'website_sale_product_configurator.optional_products_modal', + { + 'product': product, + 'product_template': product_template, + 'combination': combination, + 'combination_info': combination_info, + 'add_qty': add_qty, + 'parent_name': product.name, + 'variant_values': variant_values, + 'already_configured': already_configured, + 'mode': kw.get('mode', 'add'), + 'product_custom_attribute_values': kw.get('product_custom_attribute_values', None), + 'no_attribute': kw.get('no_attribute', False), + 'custom_attribute': kw.get('custom_attribute', False), } - """ - if lang: - request.website = request.website.with_context(lang=lang) + ) - order = request.website.sale_get_order(force_create=True) - if order.state != 'draft': - request.session['sale_order_id'] = None - order = request.website.sale_get_order(force_create=True) + @route( + '/sale_product_configurator/optional_product_items', + type='json', auth='public', methods=['POST'], website=True, + ) + def optional_product_items(self, product_id, add_qty=1, **kw): + product = request.env['product.product'].browse(int(product_id)) - product_and_options = json.loads(product_and_options) - if product_and_options: - # The main product is the first, optional products are the rest - main_product = product_and_options[0] - value = order._cart_update( - product_id=main_product['product_id'], - add_qty=main_product['quantity'], - product_custom_attribute_values=main_product['product_custom_attribute_values'], - no_variant_attribute_values=main_product['no_variant_attribute_values'], - **kwargs - ) - - if value['line_id']: - # Link option with its parent iff line has been created. - option_parent = {main_product['unique_id']: value['line_id']} - for option in product_and_options[1:]: - parent_unique_id = option['parent_unique_id'] - option_value = order._cart_update( - product_id=option['product_id'], - set_qty=option['quantity'], - linked_line_id=option_parent[parent_unique_id], - product_custom_attribute_values=option['product_custom_attribute_values'], - no_variant_attribute_values=option['no_variant_attribute_values'], - **kwargs - ) - option_parent[option['unique_id']] = option_value['line_id'] - - request.session['website_sale_cart_quantity'] = order.cart_quantity - - return str(order.cart_quantity) + return request.env['ir.ui.view']._render_template( + 'website_sale_product_configurator.optional_product_items', + { + 'product': product, + 'parent_name': product.name, + 'parent_combination': product.product_template_attribute_value_ids, + 'add_qty': float(add_qty) or 1.0, + } + ) diff --git a/addons/website_sale_product_configurator/controllers/sale_product_configurator.py b/addons/website_sale_product_configurator/controllers/sale_product_configurator.py deleted file mode 100644 index aa2e6c284a7..00000000000 --- a/addons/website_sale_product_configurator/controllers/sale_product_configurator.py +++ /dev/null @@ -1,94 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import http -from odoo.http import request - - -class ProductConfiguratorController(http.Controller): - @http.route(['/sale_product_configurator/configure'], type='json', auth="user", methods=['POST']) - def configure(self, product_template_id, pricelist_id, **kw): - add_qty = float(kw.get('add_qty', 1)) - product_template = request.env['product.template'].browse(int(product_template_id)) - pricelist = self._get_pricelist(pricelist_id) - - product_combination = False - attribute_value_ids = set(kw.get('product_template_attribute_value_ids', [])) - attribute_value_ids |= set(kw.get('product_no_variant_attribute_value_ids', [])) - if attribute_value_ids: - product_combination = request.env['product.template.attribute.value'].browse( - attribute_value_ids - ).filtered( - lambda ptav: ptav.product_tmpl_id == product_template - ) # Filter out ptavs not belonging to the given template - # It happens when you change the template on an already configured line - # receiving the configured attributes data from the previous template configuration. - - if pricelist: - product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id) - - return request.env['ir.ui.view']._render_template( - "website_sale_product_configurator.configure", - { - 'product': product_template, - 'pricelist': pricelist, - 'add_qty': add_qty, - 'product_combination': product_combination - }, - ) - - @http.route(['/sale_product_configurator/show_advanced_configurator'], type='json', auth="user", methods=['POST']) - def show_advanced_configurator(self, product_id, variant_values, pricelist_id, **kw): - pricelist = self._get_pricelist(pricelist_id) - return self._show_advanced_configurator(product_id, variant_values, pricelist, **kw) - - @http.route(['/sale_product_configurator/optional_product_items'], type='json', auth="user", methods=['POST']) - def optional_product_items(self, product_id, pricelist_id, **kw): - pricelist = self._get_pricelist(pricelist_id) - return self._optional_product_items(product_id, pricelist, **kw) - - def _optional_product_items(self, product_id, pricelist, **kw): - add_qty = float(kw.get('add_qty', 1)) - product = request.env['product.product'].browse(int(product_id)) - - parent_combination = product.product_template_attribute_value_ids - if product.env.context.get('no_variant_attribute_values'): - # Add "no_variant" attribute values' exclusions - # They are kept in the context since they are not linked to this product variant - parent_combination |= product.env.context.get('no_variant_attribute_values') - - return request.env['ir.ui.view']._render_template("website_sale_product_configurator.optional_product_items", { - 'product': product, - 'parent_name': product.name, - 'parent_combination': parent_combination, - 'pricelist': pricelist, - 'add_qty': add_qty, - }) - - def _show_advanced_configurator(self, product_id, variant_values, pricelist, **kw): - product = request.env['product.product'].browse(int(product_id)) - combination = request.env['product.template.attribute.value'].browse(variant_values) - add_qty = float(kw.get('add_qty', 1)) - - no_variant_attribute_values = combination.filtered( - lambda product_template_attribute_value: product_template_attribute_value.attribute_id.create_variant == 'no_variant' - ) - if no_variant_attribute_values: - product = product.with_context(no_variant_attribute_values=no_variant_attribute_values) - - return request.env['ir.ui.view']._render_template("website_sale_product_configurator.optional_products_modal", { - 'product': product, - 'combination': combination, - 'add_qty': add_qty, - 'parent_name': product.name, - 'variant_values': variant_values, - 'pricelist': pricelist, - 'already_configured': kw.get("already_configured", False), - 'mode': kw.get('mode', 'add'), - 'product_custom_attribute_values': kw.get('product_custom_attribute_values', None), - 'no_attribute': kw.get('no_attribute', False), - 'custom_attribute': kw.get('custom_attribute', False) - }) - - def _get_pricelist(self, pricelist_id, pricelist_fallback=False): - return request.env['product.pricelist'].browse(int(pricelist_id or 0)) diff --git a/addons/website_sale_product_configurator/controllers/website_sale.py b/addons/website_sale_product_configurator/controllers/website_sale.py new file mode 100644 index 00000000000..78f46a84579 --- /dev/null +++ b/addons/website_sale_product_configurator/controllers/website_sale.py @@ -0,0 +1,72 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json + +from odoo.http import request, route + +from odoo.addons.website_sale.controllers import main + + +class WebsiteSale(main.WebsiteSale): + + def _prepare_product_values(self, product, category, search, **kwargs): + values = super()._prepare_product_values(product, category, search, **kwargs) + + values['optional_product_ids'] = [p.with_context(active_id=p.id) for p in product.optional_product_ids] + return values + + @route('/shop/cart/update_option', type='http', auth="public", methods=['POST'], website=True, multilang=False) + def cart_options_update_json(self, product_and_options, goto_shop=None, lang=None, **kwargs): + """This route is called when submitting the optional product modal. + The product without parent is the main product, the other are options. + Options need to be linked to their parents with a unique ID. + The main product is the first product in the list and the options + need to be right after their parent. + product_and_options { + 'product_id', + 'product_template_id', + 'quantity', + 'parent_unique_id', + 'unique_id', + 'product_custom_attribute_values', + 'no_variant_attribute_values' + } + """ + if lang: + request.website = request.website.with_context(lang=lang) + + order = request.website.sale_get_order(force_create=True) + if order.state != 'draft': + request.session['sale_order_id'] = None + order = request.website.sale_get_order(force_create=True) + + product_and_options = json.loads(product_and_options) + if product_and_options: + # The main product is the first, optional products are the rest + main_product = product_and_options[0] + value = order._cart_update( + product_id=main_product['product_id'], + add_qty=main_product['quantity'], + product_custom_attribute_values=main_product['product_custom_attribute_values'], + no_variant_attribute_values=main_product['no_variant_attribute_values'], + **kwargs + ) + + if value['line_id']: + # Link option with its parent iff line has been created. + option_parent = {main_product['unique_id']: value['line_id']} + for option in product_and_options[1:]: + parent_unique_id = option['parent_unique_id'] + option_value = order._cart_update( + product_id=option['product_id'], + set_qty=option['quantity'], + linked_line_id=option_parent[parent_unique_id], + product_custom_attribute_values=option['product_custom_attribute_values'], + no_variant_attribute_values=option['no_variant_attribute_values'], + **kwargs + ) + option_parent[option['unique_id']] = option_value['line_id'] + + request.session['website_sale_cart_quantity'] = order.cart_quantity + + return str(order.cart_quantity) diff --git a/addons/website_sale_product_configurator/static/src/js/product_configurator_modal.js b/addons/website_sale_product_configurator/static/src/js/product_configurator_modal.js deleted file mode 100644 index 9ee9f27bf96..00000000000 --- a/addons/website_sale_product_configurator/static/src/js/product_configurator_modal.js +++ /dev/null @@ -1,36 +0,0 @@ -/** @odoo-module **/ - -import { OptionalProductsModal } from "@website_sale_product_configurator/js/sale_product_configurator_modal"; - -OptionalProductsModal.include({ - /** - * If the "isWebsite" param is true, will also disable the following events: - * - change [data-attribute_exclusions] - * - click button.js_add_cart_json - * - * This has to be done because those events are already registered at the "website_sale" - * component level. - * This modal is part of the form that has these events registered and we - * want to avoid duplicates. - * - * @override - * @param {$.Element} parent The parent container - * @param {Object} params - * @param {boolean} params.isWebsite If we're on a web shop page, we need some - * custom behavior - */ - init: function (parent, params) { - this._super.apply(this, arguments); - this.isWebsite = params.isWebsite; - this.forceDialog = params.forceDialog; - - this.dialogClass = 'oe_advanced_configurator_modal' + (params.isWebsite ? ' oe_website_sale' : ''); - }, - /** - * @override - * @private - */ - _triggerPriceUpdateOnChangeQuantity: function () { - return !this.isWebsite; - } -}); diff --git a/addons/website_sale_product_configurator/static/src/js/sale_product_configurator_modal.js b/addons/website_sale_product_configurator/static/src/js/sale_product_configurator_modal.js index 750cb36cfb8..98d6a6b9fa4 100644 --- a/addons/website_sale_product_configurator/static/src/js/sale_product_configurator_modal.js +++ b/addons/website_sale_product_configurator/static/src/js/sale_product_configurator_modal.js @@ -20,6 +20,8 @@ export const OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, * @param {$.Element} parent The parent container * @param {Object} params * @param {integer} params.pricelistId + * @param {boolean} params.isWebsite If we're on a web shop page, we need some + * custom behavior * @param {string} params.okButtonText The text to apply on the "ok" button, typically * "Add" for the sale order and "Proceed to checkout" on the web shop * @param {string} params.cancelButtonText same as "params.okButtonText" but @@ -53,6 +55,10 @@ export const OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, this._super(parent, options); + this.isWebsite = params.isWebsite; + this.forceDialog = params.forceDialog; + + this.dialogClass = 'oe_advanced_configurator_modal' + (params.isWebsite ? ' oe_website_sale' : ''); this.context = params.context; this.rootProduct = params.rootProduct; this.container = parent; @@ -74,20 +80,22 @@ export const OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, willStart: function () { var self = this; - var uri = this._getUri("/sale_product_configurator/show_advanced_configurator"); - var getModalContent = ajax.jsonRpc(uri, 'call', { - mode: self.mode, - product_id: self.rootProduct.product_id, - variant_values: self.rootProduct.variant_values, - product_custom_attribute_values: self.rootProduct.product_custom_attribute_values, - pricelist_id: self.pricelistId || false, - add_qty: self.rootProduct.quantity, - force_dialog: self.forceDialog, - no_attribute: self.rootProduct.no_variant_attribute_values, - custom_attribute: self.rootProduct.product_custom_attribute_values, - context: Object.assign({'quantity': self.rootProduct.quantity}, this.context), - }) - .then(function (modalContent) { + var getModalContent = ajax.jsonRpc( + '/sale_product_configurator/show_advanced_configurator', + 'call', + { + mode: self.mode, + product_id: self.rootProduct.product_id, + variant_values: self.rootProduct.variant_values, + product_custom_attribute_values: self.rootProduct.product_custom_attribute_values, + pricelist_id: self.pricelistId || false, + add_qty: self.rootProduct.quantity, + force_dialog: self.forceDialog, + no_attribute: self.rootProduct.no_variant_attribute_values, + custom_attribute: self.rootProduct.product_custom_attribute_values, + context: Object.assign({'quantity': self.rootProduct.quantity}, this.context), + } + ).then(function (modalContent) { if (modalContent) { var $modalContent = $(modalContent); $modalContent = self._postProcessContent($modalContent); @@ -374,10 +382,14 @@ export const OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, ).then(function (productId) { $parent.find('.product_id').val(productId); - ajax.jsonRpc(self._getUri("/sale_product_configurator/optional_product_items"), 'call', { - 'product_id': productId, - 'pricelist_id': self.pricelistId || false, - }).then(function (addedItem) { + ajax.jsonRpc( + '/sale_product_configurator/optional_product_items', + 'call', + { + 'product_id': productId, + 'pricelist_id': self.pricelistId || false, + } + ).then(function (addedItem) { var $addedItem = $(addedItem); $modal.find('tr:last').after($addedItem); @@ -504,7 +516,7 @@ export const OptionalProductsModal = Dialog.extend(ServicesMixin, VariantMixin, * @private */ _triggerPriceUpdateOnChangeQuantity: function () { - return true; + return !this.isWebsite; }, /** * Returns a unique id for `$el`. diff --git a/addons/website_sale_product_configurator/static/src/js/website_sale_options.js b/addons/website_sale_product_configurator/static/src/js/website_sale_options.js index b0b95096d94..9e4b737beea 100644 --- a/addons/website_sale_product_configurator/static/src/js/website_sale_options.js +++ b/addons/website_sale_product_configurator/static/src/js/website_sale_options.js @@ -83,26 +83,23 @@ publicWidget.registry.WebsiteSale.include({ */ _onModalSubmit: function (goToShop) { const self = this; - const $product = $('#product_detail'); - let currency; - if ($product.length) { - currency = $product.data('product-tracking-info')['currency']; - } else { - // Add to cart from /shop page - currency = this.$('[itemprop="priceCurrency"]').first().text(); - } - const productsTrackingInfo = []; - this.$('.js_product.in_cart').each((i, el) => { - productsTrackingInfo.push({ - 'item_id': el.getElementsByClassName('product_id')[0].value, - 'item_name': el.getElementsByClassName('product_display_name')[0].textContent, - 'quantity': el.getElementsByClassName('js_quantity')[0].value, - 'currency': currency, - 'price': el.getElementsByClassName('oe_price')[0].getElementsByClassName('oe_currency_value')[0].textContent, + const mainProduct = this.$('.js_product.in_cart.main_product').children('.product_id'); + const productTrackingInfo = mainProduct.data('product-tracking-info'); + if (productTrackingInfo) { + const currency = productTrackingInfo['currency']; + const productsTrackingInfo = []; + this.$('.js_product.in_cart').each((i, el) => { + productsTrackingInfo.push({ + 'item_id': el.getElementsByClassName('product_id')[0].value, + 'item_name': el.getElementsByClassName('product_display_name')[0].textContent, + 'quantity': el.getElementsByClassName('js_quantity')[0].value, + 'currency': currency, + 'price': el.getElementsByClassName('oe_price')[0].getElementsByClassName('oe_currency_value')[0].textContent, + }); }); - }); - if (productsTrackingInfo) { - this.$el.trigger('add_to_cart_event', productsTrackingInfo); + if (productsTrackingInfo) { + this.$el.trigger('add_to_cart_event', productsTrackingInfo); + } } this.optionalProductsModal.getAndCreateSelectedProducts() diff --git a/addons/website_sale_product_configurator/views/templates.xml b/addons/website_sale_product_configurator/views/templates.xml index c669fc401c1..fec659110e0 100644 --- a/addons/website_sale_product_configurator/views/templates.xml +++ b/addons/website_sale_product_configurator/views/templates.xml @@ -7,7 +7,8 @@ - - - @@ -81,25 +30,35 @@
- - - - + + + + - - - - - - - - @@ -159,10 +111,7 @@ + t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/> @@ -178,10 +127,10 @@
ProductQuantityPrice + Product + + + + + Quantity + + + Price +
- Product Image - Product Image + + + + Product Image + Product Image +
@@ -115,40 +74,33 @@
- - + + - +
    -
    -
    +
    +
- +
+ t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/> + t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>

Option not available