diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 6b850ba296d..f3f57d86bbd 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -1322,34 +1322,6 @@ class ProductTemplate(models.Model): return _("There are no possible combination.") combination = combination[:-1] - def _get_current_company(self, **kwargs): - """Get the most appropriate company for this product. - - If the company is set on the product, directly return it. Otherwise, - fallback to a contextual company. - - :param kwargs: kwargs forwarded to the fallback method. - - :return: the most appropriate company for this product - :rtype: recordset of one `res.company` - """ - self.ensure_one() - return self.company_id or self._get_current_company_fallback(**kwargs) - - def _get_current_company_fallback(self, **kwargs): - """Fallback to get the most appropriate company for this product. - - This should only be called from `_get_current_company` but is defined - separately to allow override. - - The final fallback will be the current user's company. - - :return: the fallback company for this product - :rtype: recordset of one `res.company` - """ - self.ensure_one() - return self.env.company - def _get_placeholder_filename(self, field): image_fields = ['image_%s' % size for size in [1920, 1024, 512, 256, 128]] if field in image_fields: 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..cb0ecd11c26 100644 --- a/addons/sale/models/product_template.py +++ b/addons/sale/models/product_template.py @@ -1,48 +1,47 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import json -import logging - from odoo import api, fields, models, _ from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP from odoo.exceptions import ValidationError from odoo.tools.float_utils import float_round -_logger = logging.getLogger(__name__) - class ProductTemplate(models.Model): _inherit = 'product.template' service_type = fields.Selection( - [('manual', 'Manually set quantities on order')], string='Track Service', + selection=[('manual', "Manually set quantities on order")], + string="Track Service", compute='_compute_service_type', store=True, readonly=False, precompute=True, help="Manually set quantities on order: Invoice based on the manually entered quantity, without creating an analytic account.\n" "Timesheets on contract: Invoice based on the tracked hours on the related timesheet.\n" "Create a task and track hours: Create a task on the sales order validation and track the work hours.") - sale_line_warn = fields.Selection(WARNING_MESSAGE, 'Sales Order Line', help=WARNING_HELP, required=True, default="no-message") - sale_line_warn_msg = fields.Text('Message for Sales Order Line') + sale_line_warn = fields.Selection( + WARNING_MESSAGE, string="Sales Order Line", + help=WARNING_HELP, required=True, default="no-message") + sale_line_warn_msg = fields.Text(string="Message for Sales Order Line") expense_policy = fields.Selection( - [('no', 'No'), - ('cost', 'At cost'), - ('sales_price', 'Sales price') - ], string='Re-Invoice Expenses', default='no', + selection=[ + ('no', "No"), + ('cost', "At cost"), + ('sales_price', "Sales price"), + ], + string="Re-Invoice Expenses", default='no', compute='_compute_expense_policy', store=True, readonly=False, help="Validated expenses and vendor bills can be re-invoiced to a customer at its cost or sales price.") - visible_expense_policy = fields.Boolean("Re-Invoice Policy visible", compute='_compute_visible_expense_policy') - sales_count = fields.Float(compute='_compute_sales_count', string='Sold', digits='Product Unit of Measure') - visible_qty_configurator = fields.Boolean("Quantity visible in configurator", compute='_compute_visible_qty_configurator') + visible_expense_policy = fields.Boolean( + string="Re-Invoice Policy visible", compute='_compute_visible_expense_policy') + sales_count = fields.Float( + string="Sold", compute='_compute_sales_count', digits='Product Unit of Measure') invoice_policy = fields.Selection( - [('order', 'Ordered quantities'), - ('delivery', 'Delivered quantities')], string='Invoicing Policy', + selection=[ + ('order', "Ordered quantities"), + ('delivery', "Delivered quantities"), + ], + string="Invoicing Policy", compute='_compute_invoice_policy', store=True, readonly=False, precompute=True, - help='Ordered Quantity: Invoice quantities ordered by the customer.\n' - 'Delivered Quantity: Invoice quantities delivered to the customer.') - - def _compute_visible_qty_configurator(self): - for product_template in self: - product_template.visible_qty_configurator = True + help="Ordered Quantity: Invoice quantities ordered by the customer.\n" + "Delivered Quantity: Invoice quantities delivered to the customer.") @api.depends('name') def _compute_visible_expense_policy(self): @@ -80,7 +79,7 @@ class ProductTemplate(models.Model): 'shared product.') % (target_company.name, ', '.join(used_products))) def action_view_sales(self): - action = self.env["ir.actions.actions"]._for_xml_id("sale.report_all_channels_sales_action") + action = self.env['ir.actions.actions']._for_xml_id('sale.report_all_channels_sales_action') action['domain'] = [('product_tmpl_id', 'in', self.ids)] action['context'] = { 'pivot_measures': ['product_uom_qty'], @@ -91,42 +90,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() @@ -151,153 +114,7 @@ class ProductTemplate(models.Model): if self.env.context.get('sale_multi_pricelist_product_template'): if self.user_has_groups('product.group_sale_pricelist'): return [{ - 'label': _('Import Template for Products'), + 'label': _("Import Template for Products"), '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_event_booth_sale/controllers/event_booth.py b/addons/website_event_booth_sale/controllers/event_booth.py index fe4418c97e1..9520142cb3b 100644 --- a/addons/website_event_booth_sale/controllers/event_booth.py +++ b/addons/website_event_booth_sale/controllers/event_booth.py @@ -9,13 +9,6 @@ from odoo.addons.website_event.controllers.main import WebsiteEventController class WebsiteEventBoothController(WebsiteEventController): - @route() - def event_booth_main(self, event, booth_category_id=False, booth_ids=False): - pricelist = request.website.pricelist_id - if pricelist: - event = event.with_context(pricelist=pricelist.id) - return super(WebsiteEventBoothController, self).event_booth_main(event, booth_category_id, booth_ids) - @route() def event_booth_registration_confirm(self, event, booth_category_id, event_booth_ids, **kwargs): """Override: Doesn't call the parent method because we go through the checkout diff --git a/addons/website_event_sale/controllers/main.py b/addons/website_event_sale/controllers/main.py index 8f776b795fd..818e8751e08 100644 --- a/addons/website_event_sale/controllers/main.py +++ b/addons/website_event_sale/controllers/main.py @@ -10,14 +10,6 @@ from odoo.addons.website_event.controllers.main import WebsiteEventController class WebsiteEventSaleController(WebsiteEventController): - @route() - def event_register(self, event, **post): - if not request.context.get('pricelist'): - pricelist = request.website.pricelist_id - if pricelist: - event = event.with_context(pricelist=pricelist.id) - return super().event_register(event, **post) - def _process_tickets_form(self, event, form_details): """ Add price information on ticket order """ res = super()._process_tickets_form(event, form_details) diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index cb2adc4c829..bc3187a97e5 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -328,14 +328,20 @@ class WebsiteSale(http.Controller): keep = QueryURL('/shop', **self._shop_get_query_url_kwargs(category and int(category), search, min_price, max_price, **post)) now = datetime.timestamp(datetime.now()) - pricelist = website.get_current_pricelist() - if request.session.get('website_sale_pricelist_time', 0) < now - 60*60: # test: 1 hour in session - pricelist = website.pricelist_id + pricelist = website.pricelist_id + if 'website_sale_pricelist_time' in request.session: + # Check if we need to refresh the cached pricelist + pricelist_save_time = request.session['website_sale_pricelist_time'] + if pricelist_save_time < now - 60*60: + request.session.pop('website_sale_current_pl', None) + website.invalidate_recordset(['pricelist_id']) + pricelist = website.pricelist_id + request.session['website_sale_pricelist_time'] = now + request.session['website_sale_current_pl'] = pricelist.id + else: 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) - filter_by_price_enabled = website.is_view_active('website_sale.filter_products_price') if filter_by_price_enabled: company_currency = website.company_id.currency_id @@ -344,9 +350,9 @@ class WebsiteSale(http.Controller): else: conversion_rate = 1 - url = "/shop" + url = '/shop' if search: - post["search"] = search + post['search'] = search if attrib_list: post['attrib'] = attrib_list @@ -440,7 +446,9 @@ 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_sudo = website.fiscal_position_id.sudo() + products_prices = lazy(lambda: products._get_sales_prices(pricelist, fiscal_position_sudo)) values = { 'search': fuzzy_search_term or search, @@ -451,6 +459,7 @@ class WebsiteSale(http.Controller): 'attrib_set': attrib_set, 'pager': pager, 'pricelist': pricelist, + 'fiscal_position': fiscal_position_sudo, 'add_qty': add_qty, 'products': products, 'search_product': search_product, @@ -539,8 +548,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 @@ -674,7 +682,7 @@ class WebsiteSale(http.Controller): return { 'search': search, 'category': category, - 'pricelist': request.website.get_current_pricelist(), + 'pricelist': request.website.pricelist_id, 'attrib_values': attrib_values, 'attrib_set': attrib_set, 'keep': keep, @@ -697,7 +705,7 @@ class WebsiteSale(http.Controller): min_price = args.get('min_price') max_price = args.get('max_price') if min_price or max_price: - previous_price_list = request.website.get_current_pricelist() + previous_price_list = request.website.pricelist_id try: min_price = float(min_price) args['min_price'] = min_price and str( @@ -1881,15 +1889,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 +1929,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..d25e9469764 100644 --- a/addons/website_sale/models/__init__.py +++ b/addons/website_sale/models/__init__.py @@ -15,6 +15,8 @@ from . import product_public_category from . import product_ribbon from . import product_tag from . import product_template +from . import product_template_attribute_line +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_attribute.py b/addons/website_sale/models/product_attribute.py index 805d5cfa9c3..eb502e18b2a 100644 --- a/addons/website_sale/models/product_attribute.py +++ b/addons/website_sale/models/product_attribute.py @@ -1,32 +1,11 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from collections import OrderedDict - from odoo import models, fields class ProductAttribute(models.Model): _inherit = 'product.attribute' - visibility = fields.Selection([('visible', 'Visible'), ('hidden', 'Hidden')], default='visible') - - -class ProductTemplateAttributeLine(models.Model): - _inherit = 'product.template.attribute.line' - - def _prepare_single_value_for_display(self): - """On the product page group together the attribute lines that concern - the same attribute and that have only one value each. - - Indeed those are considered informative values, they do not generate - choice for the user, so they are displayed below the configurator. - - The returned attributes are ordered as they appear in `self`, so based - on the order of the attribute lines. - """ - single_value_lines = self.filtered(lambda ptal: len(ptal.value_ids) == 1) - single_value_attributes = OrderedDict([(pa, self.env['product.template.attribute.line']) for pa in single_value_lines.attribute_id]) - for ptal in single_value_lines: - single_value_attributes[ptal.attribute_id] |= ptal - return single_value_attributes + visibility = fields.Selection( + selection=[('visible', "Visible"), ('hidden', "Hidden")], + default='visible') 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..c9c2bdf694f 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,298 @@ 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 + fiscal_position = website.fiscal_position_id.sudo() + + + 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 +547,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 +608,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 +616,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 +631,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 +740,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 +758,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_line.py b/addons/website_sale/models/product_template_attribute_line.py new file mode 100644 index 00000000000..f2e6a9c17d3 --- /dev/null +++ b/addons/website_sale/models/product_template_attribute_line.py @@ -0,0 +1,25 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import OrderedDict + +from odoo import models + + +class ProductTemplateAttributeLine(models.Model): + _inherit = 'product.template.attribute.line' + + def _prepare_single_value_for_display(self): + """On the product page group together the attribute lines that concern + the same attribute and that have only one value each. + + Indeed those are considered informative values, they do not generate + choice for the user, so they are displayed below the configurator. + + The returned attributes are ordered as they appear in `self`, so based + on the order of the attribute lines. + """ + single_value_lines = self.filtered(lambda ptal: len(ptal.value_ids) == 1) + single_value_attributes = OrderedDict([(pa, self.env['product.template.attribute.line']) for pa in single_value_lines.attribute_id]) + for ptal in single_value_lines: + single_value_attributes[ptal.attribute_id] |= ptal + return single_value_attributes 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..fb401b48712 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): @@ -35,6 +31,8 @@ class Website(models.Model): required=True, default='tax_excluded', ) + fiscal_position_id = fields.Many2one( + 'account.fiscal.position', compute='_compute_fiscal_position_id') pricelist_id = fields.Many2one( 'product.pricelist', compute='_compute_pricelist_id', string="Default Pricelist if any") currency_id = fields.Many2one( @@ -125,11 +123,11 @@ class Website(models.Model): def _compute_pricelist_id(self): for website in self: - website.pricelist_id = website.get_current_pricelist() + website.pricelist_id = website._get_current_pricelist() - # NOTE VFE: moving this computation doesn't change much - # Because most of it must still be computed for the pricelist choice template (`pricelist_list`) - # Therefore, avoiding all pricelist computation is impossible in fact... + def _compute_fiscal_position_id(self): + for website in self: + website.fiscal_position_id = website._get_current_fiscal_position() @api.depends('all_pricelist_ids', 'pricelist_id', 'company_id') def _compute_currency_id(self): @@ -252,7 +250,7 @@ class Website(models.Model): def _get_cached_pricelist_id(self): return request and request.session.get('website_sale_current_pl') or None - def get_current_pricelist(self): + def _get_current_pricelist(self): """ :returns: The current pricelist record """ @@ -387,7 +385,9 @@ class Website(models.Model): # Reset the session pricelist according to logged partner pl request.session.pop('website_sale_current_pl', None) - pricelist_id = self._get_current_pricelist_id(partner_sudo) + # Force recomputation of the website pricelist after reset + self.invalidate_recordset(['pricelist_id']) + pricelist_id = self.pricelist_id.id request.session['website_sale_current_pl'] = pricelist_id # change the partner, and trigger the computes (fpos) @@ -406,7 +406,7 @@ class Website(models.Model): update_pricelist = True elif update_pricelist: # Only compute pricelist if needed - pricelist_id = self._get_current_pricelist_id(partner_sudo) + pricelist_id = self.pricelist_id.id # update the pricelist if update_pricelist: @@ -434,18 +434,15 @@ class Website(models.Model): if not salesperson_user_sudo: salesperson_user_sudo = self.salesperson_id or partner_sudo.parent_id.user_id or partner_sudo.user_id - pricelist_id = self._get_current_pricelist_id(partner_sudo) - fiscal_position_id = self._get_current_fiscal_position_id(partner_sudo) - values = { 'company_id': self.company_id.id, - 'fiscal_position_id': fiscal_position_id, + 'fiscal_position_id': self.fiscal_position_id.id, 'partner_id': partner_sudo.id, 'partner_invoice_id': partner_sudo.id, 'partner_shipping_id': addr['delivery'], - 'pricelist_id': pricelist_id, + 'pricelist_id': self.pricelist_id.id, 'payment_term_id': self.sale_get_payment_term(partner_sudo), 'team_id': self.salesteam_id.id or partner_sudo.parent_id.team_id.id or partner_sudo.team_id.id, @@ -467,26 +464,24 @@ class Website(models.Model): self.env['account.payment.term'].sudo().search([('company_id', '=', self.company_id.id)], limit=1) ).id - def _get_current_pricelist_id(self, partner_sudo): - return self.get_current_pricelist().id \ - or partner_sudo.property_product_pricelist.id - - @api.model - def _get_current_fiscal_position_id(self, partner_sudo): + def _get_current_fiscal_position(self): AccountFiscalPosition = self.env['account.fiscal.position'].sudo() fpos = AccountFiscalPosition + partner_sudo = self.env.user.partner_id # 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) - return fpos.id + return fpos def sale_reset(self): request.session.pop('sale_order_id', None) @@ -568,6 +563,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_customize.py b/addons/website_sale/tests/test_customize.py index a475d142a01..7b0e8fbbf20 100644 --- a/addons/website_sale/tests/test_customize.py +++ b/addons/website_sale/tests/test_customize.py @@ -57,7 +57,7 @@ class TestUi(HttpCaseWithUserDemo, HttpCaseWithUserPortal): # Update the pricelist currency regarding env.company_id currency_id in case company has changed currency with COA installation. website = self.env['website'].get_current_website() - pricelist = website.get_current_pricelist() + pricelist = website.pricelist_id pricelist.write({'currency_id': self.env.company.currency_id.id}) def test_01_admin_shop_customize_tour(self): diff --git a/addons/website_sale/tests/test_website_sale_pricelist.py b/addons/website_sale/tests/test_website_sale_pricelist.py index 3865ecb7f25..9913e881bbc 100644 --- a/addons/website_sale/tests/test_website_sale_pricelist.py +++ b/addons/website_sale/tests/test_website_sale_pricelist.py @@ -195,7 +195,7 @@ class TestWebsitePriceList(TransactionCase): 'taxes_id': False, }) current_website = self.env['website'].get_current_website() - website_pricelist = current_website.get_current_pricelist() + website_pricelist = current_website.pricelist_id website_pricelist.write({ 'discount_policy': 'with_discount', 'item_ids': [(5, 0, 0), (0, 0, { @@ -246,7 +246,7 @@ class TestWebsitePriceList(TransactionCase): 'taxes_id': False, }) current_website = self.env['website'].get_current_website() - website_pricelist = current_website.get_current_pricelist() + website_pricelist = current_website.pricelist_id website_pricelist.write({ 'discount_policy': 'without_discount', 'item_ids': [(5, 0, 0), (0, 0, { @@ -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..802ebe727c0 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,8 @@ 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) + website.invalidate_recordset(['fiscal_position_id']) + 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 +152,16 @@ class TestWebsiteSaleProductAttributeValueConfig(AccountTestInvoicingCommon, Tes # Reset / Safety check self.env.user.partner_id.country_id = None - combination_info = product._get_combination_info(pricelist=pricelist) + website.invalidate_recordset(['fiscal_position_id']) + 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) + website.invalidate_recordset(['fiscal_position_id']) + 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..d94f8ec15cb 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -356,7 +356,7 @@
-
- |
@@ -1725,8 +1730,13 @@
|
- |
diff --git a/addons/website_sale/views/variant_templates.xml b/addons/website_sale/views/variant_templates.xml
index 0feea14ec23..6c72cc2b635 100644
--- a/addons/website_sale/views/variant_templates.xml
+++ b/addons/website_sale/views/variant_templates.xml
@@ -118,27 +118,25 @@
- | |