[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
This commit is contained in:
Victor Feyens
2023-09-14 18:09:48 +00:00
parent e1cf7e93c9
commit 4069158c96
41 changed files with 837 additions and 1065 deletions
+5 -11
View File
@@ -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'])
-182
View File
@@ -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
@@ -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})
+13 -9
View File
@@ -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)
+29 -31
View File
@@ -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))
+1
View File
@@ -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
@@ -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()
+296 -107
View File
@@ -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):
@@ -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
+8 -10
View File
@@ -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'
@@ -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);
@@ -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");
@@ -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,
},
@@ -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):
@@ -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)
@@ -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%)")
+43 -30
View File
@@ -356,7 +356,7 @@
<div class="mb16" id="category_header" t-att-data-editor-message="editor_msg" t-field="category.website_description"/>
</t>
<div t-cache="pricelist,products" t-if="products" class="o_wsale_products_grid_table_wrapper pt-3 pt-lg-0">
<div t-cache="pricelist,fiscal_position,products" t-if="products" class="o_wsale_products_grid_table_wrapper pt-3 pt-lg-0">
<table class="table table-borderless h-100 m-0" t-att-data-ppg="ppg" t-att-data-ppr="ppr" t-att-data-default-sort="website.shop_default_sort" t-att-data-name="grid_block_name">
<colgroup t-ignore="true">
<!-- Force the number of columns (useful when only one row of (x < ppr) products) -->
@@ -948,16 +948,18 @@
<t t-cache="pricelist,product">
<t t-set="combination" t-value="product._get_first_possible_combination()"/>
<t t-set="combination_info" t-value="product._get_combination_info(combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
<t t-set="combination_info" t-value="product._get_combination_info(combination, add_qty=add_qty)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<t t-call="website.layout">
<t t-set="additional_title" t-value="product.name" />
<div itemscope="itemscope" itemtype="http://schema.org/Product" id="wrap" class="js_sale o_wsale_product_page">
<div class="oe_structure oe_empty oe_structure_not_nearest" id="oe_structure_website_sale_product_1" data-editor-message="DROP BUILDING BLOCKS HERE TO MAKE THEM AVAILABLE ACROSS ALL PRODUCTS"/>
<section t-attf-class="container py-4 oe_website_sale #{'discount' if combination_info['has_discounted_price'] else ''}" id="product_detail"
t-att-data-view-track="view_track and '1' or '0'"
t-att-data-product-tracking-info="json.dumps(request.env['product.template'].get_google_analytics_data(combination_info))">
<section id="product_detail"
t-attf-class="container py-4 oe_website_sale #{'discount' if combination_info['has_discounted_price'] else ''}"
t-att-data-view-track="view_track and '1' or '0'"
t-att-data-product-tracking-info="'product_tracking_info' in combination_info and json.dumps(combination_info['product_tracking_info'])"
>
<div class="row align-items-center">
<div class="col-lg-6 d-flex align-items-center">
<div class="d-flex justify-content-between w-100">
@@ -1259,8 +1261,11 @@
<template id="product_price">
<div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" t-attf-class="product_price mt-2 mb-3 {{'d-none' if combination_info['prevent_zero_price_sale'] else 'd-inline-block'}}">
<h3 class="css_editable_mode_hidden">
<span class="oe_price" style="white-space: nowrap;" t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span itemprop="price" style="display:none;" t-esc="combination_info['price']"/>
<span class="oe_price"
style="white-space: nowrap;"
t-out="combination_info['price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span itemprop="price" style="display:none;" t-out="combination_info['price']"/>
<span itemprop="priceCurrency" style="display:none;" t-esc="website.currency_id.name"/>
<span t-attf-class="text-danger oe_default_price ms-1 h5 {{'' if combination_info['has_discounted_price'] and not combination_info['compare_list_price'] else 'd-none'}}"
style="text-decoration: line-through; white-space: nowrap;"
@@ -1273,28 +1278,19 @@
<bdi dir="inherit">
<span t-esc="combination_info['compare_list_price']"
groups="website_sale.group_product_price_comparison"
t-options='{
"widget": "monetary",
"display_currency": website.currency_id,
}'/>
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
</bdi>
</del>
</h3>
<h3 class="css_non_editable_mode_hidden decimal_precision" t-att-data-precision="str(website.currency_id.decimal_places)">
<span t-field="product.list_price"
t-options='{
"widget": "monetary",
"display_currency": product.currency_id,
}'/>
t-options="{'widget': 'monetary', 'display_currency': product.currency_id}"/>
<t t-if="is_view_active('website_sale.tax_indication')" t-call="website_sale.tax_indication"/>
<del t-if="combination_info['compare_list_price'] and (combination_info['compare_list_price'] &gt; combination_info['price'])">
<bdi dir="inherit">
<span t-field="product.compare_list_price"
groups="website_sale.group_product_price_comparison"
t-options='{
"widget": "monetary",
"display_currency": website.currency_id,
}'/>
t-options="{'widget': 'monetary', 'display_currency': product.currency_id}"/>
</bdi>
</del>
</h3>
@@ -1326,15 +1322,24 @@
<t t-if="len(filtered_sorted_variants) &gt; 1">
<div class="mb-4">
<div t-foreach="filtered_sorted_variants" t-as="variant_id" class="form-check mb-1">
<t t-set="template_combination_info" t-value="product._get_combination_info(only_template=True, add_qty=add_qty, pricelist=pricelist)"/>
<t t-set="combination_info" t-value="variant_id._get_combination_info_variant(add_qty=add_qty, pricelist=pricelist)"/>
<input type="radio" name="product_id" class="form-check-input product_id js_product_change" t-att-checked="'checked' if variant_id_index == 0 else None" t-attf-id="radio_variant_#{variant_id.id}" t-att-value="variant_id.id" t-att-data-price="combination_info['price']" t-att-data-combination="variant_id.product_template_attribute_value_ids.ids"/>
<t t-set="template_combination_info" t-value="product._get_combination_info(only_template=True, add_qty=add_qty)"/>
<t t-set="combination_info" t-value="variant_id._get_combination_info_variant(add_qty=add_qty)"/>
<input type="radio"
name="product_id"
class="form-check-input product_id js_product_change"
t-att-checked="'checked' if variant_id_index == 0 else None"
t-attf-id="radio_variant_#{variant_id.id}"
t-att-value="variant_id.id"
t-att-data-price="combination_info['price']"
t-att-data-combination="variant_id.product_template_attribute_value_ids.ids"/>
<label t-attf-for="radio_variant_#{variant_id.id}" label-default="label-default" class="form-check-label fw-normal">
<span t-esc="combination_info['display_name']"/>
<span t-out="combination_info['display_name']"/>
<t t-set="diff_price" t-value="website.currency_id.compare_amounts(combination_info['price'], template_combination_info['price'])"/>
<span t-attf-class="badge rounded-pill text-bg-{{navClass}} border" t-if="diff_price != 0">
<span class="sign_badge_price_extra" t-esc="diff_price > 0 and '+' or '-'"/>
<span t-esc="abs(combination_info['price'] - template_combination_info['price'])" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" class="text-muted fst-italic" />
<span class="sign_badge_price_extra" t-out="diff_price > 0 and '+' or '-'"/>
<span t-out="abs(combination_info['price'] - template_combination_info['price'])"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"
class="text-muted fst-italic"/>
</span>
</label>
</div>
@@ -1559,7 +1564,7 @@
</td>
<td class="text-center td-price" name="price">
<t t-set="combination" t-value="line.product_id.product_template_attribute_value_ids + line.product_no_variant_attribute_value_ids"/>
<t t-set="combination_info" t-value="line.product_id.product_tmpl_id._get_combination_info(combination, pricelist=website_sale_order.pricelist_id, add_qty=line.product_uom_qty)"/>
<t t-set="combination_info" t-value="line.product_id.product_tmpl_id._get_combination_info(combination, add_qty=line.product_uom_qty)"/>
<span t-if="website.show_line_subtotals_tax_selection == 'tax_excluded'"
t-field="line.price_reduce_taxexcl" style="white-space: nowrap;"
@@ -1568,7 +1573,10 @@
t-field="line.price_reduce_taxinc" style="white-space: nowrap;"
t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}"/>
<t t-if="line.discount">
<del t-attf-class="#{'text-danger mr8'}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" />
<del t-attf-class="#{'text-danger mr8'}"
style="white-space: nowrap;"
t-esc="combination_info['list_price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" />
</t>
<small t-if="not line._is_not_sellable_line() and line.product_id.base_unit_price"
class="cart_product_base_unit_price d-block text-muted"
@@ -1710,7 +1718,7 @@
<table t-if="suggested_products" id="suggested_products" class="js_cart_lines table table-striped table-sm">
<tbody>
<tr t-foreach="suggested_products" t-as="product" t-att-data-publish="product.website_published and 'on' or 'off'">
<t t-set="combination_info" t-value="product._get_combination_info_variant(pricelist=website_sale_order.pricelist_id)"/>
<t t-set="combination_info" t-value="product._get_combination_info_variant()"/>
<td class='td-img text-center'>
<a t-att-href="product.website_url">
<span t-field="product.image_128" t-options="{'widget': 'image', 'qweb_img_responsive': False, 'class': 'rounded o_image_64_max'}" />
@@ -1725,8 +1733,13 @@
<div class="text-muted d-none d-md-block" t-field="product.description_sale" />
</td>
<td class='td-price'>
<del t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span t-esc="combination_info['price']" style="white-space: nowrap;" t-options="{'widget': 'monetary','display_currency': website.currency_id}"/>
<del t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
style="white-space: nowrap;"
t-out="combination_info['list_price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span t-out="combination_info['price']"
style="white-space: nowrap;"
t-options="{'widget': 'monetary','display_currency': website.currency_id}"/>
</td>
<td class="w-25 text-center">
<input class="js_quantity" name="product_id" t-att-data-product-id="product.id" type="hidden" />
+19 -21
View File
@@ -118,27 +118,25 @@
</ul>
</template>
<template id="badge_extra_price" name="Badge Extra Price">
<t t-set="combination_info_variant" t-value="product._get_combination_info(ptav, pricelist=pricelist)"/>
<span class="badge rounded-pill text-bg-light border" t-if="combination_info_variant['price_extra']">
<!--
price_extra is displayed as catalog price instead of
price after pricelist because it is impossible to
compute. Indeed, the pricelist rule might depend on the
selected variant, so the price_extra will be different
depending on the selected combination. The price of an
attribute is therefore variable and it's not very
accurate to display it.
To cover some generic cases, the price_extra also
covers the price-included taxes in e-commerce flows.
(See the override of `_get_combination_info`)
-->
<span class="sign_badge_price_extra" t-out="combination_info_variant['price_extra'] > 0 and '+' or '-'"/>
<span t-out="abs(combination_info_variant['price_extra'])" class="variant_price_extra text-muted fst-italic" style="white-space: nowrap;"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product.env.company).currency_id
}'/>
<t t-set="price_extra" t-value="ptav._get_extra_price(combination_info)"/>
<span t-if="price_extra" class="badge rounded-pill text-bg-light border">
<!--
price_extra is displayed as catalog price instead of
price after pricelist because it is impossible to
compute. Indeed, the pricelist rule might depend on the
selected variant, so the price_extra will be different
depending on the selected combination. The price of an
attribute is therefore variable and it's not very
accurate to display it.
-->
<span class="sign_badge_price_extra" t-out="price_extra > 0 and '+' or '-'"/>
<span t-out="abs(price_extra)"
class="variant_price_extra text-muted fst-italic"
style="white-space: nowrap;"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product.env.company).currency_id
}'/>
</span>
</template>
</odoo>
@@ -121,22 +121,36 @@
</span>
<span class="o_comparison_price" t-else="">
<strong>Price:</strong>
<del t-if="combination_info['compare_list_price'] and (combination_info['compare_list_price'] &gt; combination_info['price'])" t-attf-class="text-danger mr8" style="white-space: nowrap;" t-esc="combination_info['compare_list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" />
<del t-else="" t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
style="white-space: nowrap;" t-esc="combination_info['list_price']"
<del t-if="combination_info['compare_list_price'] and (combination_info['compare_list_price'] &gt; combination_info['price'])"
t-attf-class="text-danger mr8"
style="white-space: nowrap;"
t-esc="combination_info['compare_list_price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" />
<del t-else=""
t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
style="white-space: nowrap;"
t-out="combination_info['list_price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span t-esc="combination_info['price']"
<span t-out="combination_info['price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<small class="d-block text-muted" groups="website_sale.group_show_uom_price" t-if="combination_info['base_unit_price']">
<small t-if="combination_info['base_unit_price']"
class="d-block text-muted"
groups="website_sale.group_show_uom_price">
<t t-call='website_sale.base_unit_price'/>
</small>
</span>
<form action="/shop/cart/update" method="post" class="text-center o_add_cart_form_compare">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input name="product_id" t-att-value="product.id" type="hidden"
t-att-data-product-tracking-info="json.dumps(request.env['product.template'].get_google_analytics_data(combination_info))"/>
<a t-if="combination_info['prevent_zero_price_sale']" t-att-href="website.contact_us_button_url" class="btn btn-primary btn_cta">Contact Us</a>
<input name="product_id"
type="hidden"
t-att-value="product.id"
t-att-data-product-tracking-info="'product_tracking_info' in combination_info and json.dumps(combination_info['product_tracking_info'])"/>
<a t-if="combination_info['prevent_zero_price_sale']"
t-att-href="website.contact_us_button_url"
class="btn btn-primary btn_cta">
Contact Us
</a>
<a t-else="" role="button" class="btn btn-primary d-block a-submit" href="#">
<i class="fa fa-shopping-cart me-2"/>Add to Cart
</a>
@@ -198,9 +212,18 @@
<h6>
<a t-att-href="product.website_url"><t t-esc="combination_info['display_name']" /></a><br/>
<div t-attf-class="{{'d-none' if combination_info['prevent_zero_price_sale'] else ''}}">
<del t-if="combination_info['compare_list_price'] and (combination_info['compare_list_price'] &gt; combination_info['price'])" t-attf-class="text-danger mr8" style="white-space: nowrap;" t-esc="combination_info['compare_list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" />
<del t-else="" t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" />
<span t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<del t-if="combination_info['compare_list_price'] and (combination_info['compare_list_price'] &gt; combination_info['price'])"
t-attf-class="text-danger mr8"
style="white-space: nowrap;"
t-esc="combination_info['compare_list_price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" />
<del t-else=""
t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
style="white-space: nowrap;"
t-esc="combination_info['list_price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span t-out="combination_info['price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
</div>
<div t-attf-class="{{'' if combination_info['prevent_zero_price_sale'] else 'd-none'}}" t-field="website.prevent_zero_price_sale_text"/>
</h6>
@@ -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',
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
from . import main
from . import sale_product_configurator
from . import website_sale
@@ -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,
}
)
@@ -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))
@@ -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)
@@ -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;
}
});
@@ -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`.
@@ -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()
@@ -7,7 +7,8 @@
</template>
<template id="product_quantity_config">
<div class="css_quantity input-group">
<div t-if="is_view_active('website_sale.product_quantity')"
class="css_quantity input-group">
<button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
<i class="fa fa-minus"></i>
</button>
@@ -21,59 +22,7 @@
<i class="fa fa-plus"></i>
</button>
</div>
</template>
<!-- backend -->
<template id="configure" name="Configure">
<div class="js_product main_product">
<t t-set="combination" t-value="product_combination if product_combination else product._get_first_possible_combination()"/>
<t t-set="combination_info" t-value="product._get_combination_info(combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<input type="hidden" class="product_template_id" t-att-value="product.id"/>
<input type="hidden" class="product_id" t-attf-name="product_id" t-att-value="product_variant.id"/>
<input type="hidden" class="has_optional_products" t-attf-name="has_optional_products"
t-att-value="product_variant.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(combination))"/>
<div class="col-lg-12 text-center mt-5">
<t t-if="product._is_add_to_cart_possible()">
<div class="col-lg-5 d-inline-block text-start">
<t t-if="combination" t-call="website_sale.variants">
<t t-set="parent_combination" t-value="None"/>
</t>
<h2>
<span t-attf-class="text-danger oe_default_price oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
t-out="combination_info['list_price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product.env.company).currency_id
}'/>
<span class="oe_price product_id mt-3" style="white-space: nowrap;"
t-att-data-product-id="product.id"
t-out="combination_info['price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product.env.company).currency_id
}'/>
</h2>
<t t-if="product.visible_qty_configurator">
<t t-call="website_sale_product_configurator.product_quantity_config" />
</t>
<p class="css_not_available_msg alert alert-warning">This combination does not exist.</p>
</div>
<div class="col-lg-1 d-inline-block"></div>
<div class="col-lg-5 d-inline-block align-top text-start">
<img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image_1024' % product_variant.id" class="d-block product_detail_img" alt="Product Image"/>
<img t-else="" t-att-src="'/web/image/product.template/%s/image_1024' % product.id" class="d-block product_detail_img" alt="Product Image"/>
</div>
</t>
<t t-else="">
<div class="col-lg-5 d-inline-block text-start">
<p class="alert alert-warning">This product has no valid combination.</p>
</div>
</t>
</div>
</div>
<input t-else="" type="hidden" class="d-none js_quantity form-control quantity" name="add_qty" t-att-value="add_qty or 1"/>
</template>
<!-- modal: full table, currenclty selected products at top -->
@@ -81,25 +30,35 @@
<table class="table table-striped table-sm">
<thead>
<tr>
<th class="td-img"><span class='label'>Product</span></th>
<th><span class='label'></span></th>
<th class="text-center td-qty"><span class='label'>Quantity</span></th>
<th class="text-center td-price"><span class='label'>Price</span></th>
<th class="td-img">
<span class="label">Product</span>
</th>
<th>
<span class="label"></span>
</th>
<th class="text-center td-qty">
<span t-if="is_view_active('website_sale.product_quantity')"
class="label">
Quantity
</span>
</th>
<th class="text-center td-price">
<span class="label">Price</span>
</th>
</tr>
</thead>
<tbody>
<tr class="js_product in_cart main_product">
<t t-set="combination_info" t-value="product.product_tmpl_id._get_combination_info(combination, product.id, add_qty or 1, pricelist)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<input type="hidden" class="product_template_id" t-att-value="product.product_tmpl_id.id"/>
<input type="hidden" class="product_id" t-att-value="product_variant.id"/>
<td class='td-img'>
<img class="product_detail_img" t-if="product_variant" t-att-src="'/web/image/product.product/%s/image_128' % product_variant.id" alt="Product Image"/>
<img class="product_detail_img" t-else="" t-att-src="'/web/image/product.template/%s/image_128' % product.id" alt="Product Image"/>
<input type="hidden" class="product_template_id" t-att-value="product_template.id"/>
<input type="hidden"
class="product_id"
t-att-value="product.id"
t-att-data-product-tracking-info="'product_tracking_info' in combination_info and json.dumps(combination_info['product_tracking_info'])"/>
<td class="td-img">
<img class="product_detail_img" t-if="product" t-att-src="'/web/image/product.product/%s/image_128' % product_id" alt="Product Image"/>
<img class="product_detail_img" t-else="" t-att-src="'/web/image/product.template/%s/image_128' % product_template_id" alt="Product Image"/>
</td>
<td class='td-product_name'>
<td class="td-product_name">
<strong class="product-name product_display_name" t-out="combination_info['display_name']"/>
<div class="text-muted small">
<div t-field="product.description_sale"/>
@@ -115,40 +74,33 @@
</div>
</div>
<div>
<t t-if="product.product_tmpl_id and not combination">
<t t-set="combination" t-value="product.product_tmpl_id._get_first_possible_combination()"/>
<t t-if="product and not combination">
<t t-set="combination" t-value="product_template._get_first_possible_combination()"/>
</t>
<t t-if="combination and not already_configured" t-call="website_sale.variants">
<t t-set="ul_class" t-valuef="flex-column" />
<t t-set="product" t-value="product.product_tmpl_id"/>
<t t-set="product" t-value="product_template"/>
</t>
<t t-else="">
<ul class="d-none js_add_cart_variants mb-0" t-att-data-attribute_exclusions="{'exclusions: []'}"/>
<div class="d-none oe_unchanged_value_ids" t-att-data-unchanged_value_ids="variant_values" ></div>
<!-- Keep the information to use it later (when leaving the modal window) -->
<div class="d-none no-attribute-info" t-att-data-attribute-value='json.dumps(no_attribute)'></div>
<div class="d-none custom-attribute-info" t-att-data-attribute-value='json.dumps(custom_attribute)'></div>
<div class="d-none no-attribute-info" t-att-data-attribute-value="json.dumps(no_attribute)"></div>
<div class="d-none custom-attribute-info" t-att-data-attribute-value="json.dumps(custom_attribute)"></div>
</t>
</div>
</td>
<td class="text-center td-qty">
<t t-call='website_sale_product_configurator.product_quantity_config' />
<t t-call="website_sale_product_configurator.product_quantity_config"/>
</td>
<td class="text-center td-price" name="price">
<div t-attf-class="text-danger oe_default_price oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
t-out="combination_info['list_price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product.env.company).currency_id
}'
/>
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span class="oe_price product_id" style="white-space: nowrap;"
t-att-data-product-id="product.id"
t-out="combination_info['price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product.env.company).currency_id
}'/>
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span class="js_raw_price d-none" t-out="product._get_contextual_price()"/>
<p class="css_not_available_msg alert alert-warning">Option not available</p>
</td>
@@ -159,10 +111,7 @@
<span class="js_price_total fw-bold" style="white-space: nowrap;"
t-att-data-product-id="product.id"
t-out="combination_info['price'] * (add_qty or 1)"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product.env.company).currency_id
}'/>
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
</td>
</tr>
<t t-if="product.optional_product_ids and mode != 'edit'">
@@ -178,10 +127,10 @@
<!-- modal: optional products -->
<template id="optional_product_items">
<t t-foreach="product.optional_product_ids" t-as="product">
<t t-set="combination" t-value="product._get_first_possible_combination(parent_combination)"/>
<t t-if="product._is_add_to_cart_possible(parent_combination)">
<t t-set="combination_info" t-value="product._get_combination_info(combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
<t t-set="combination" t-value="product._get_first_possible_combination(parent_combination)"/>
<t t-set="combination_info" t-value="product._get_combination_info(combination, add_qty=add_qty)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<tr class="js_product">
@@ -196,9 +145,7 @@
<strong class="product-name product_display_name" t-out="combination_info['display_name']"/>
<div class="text-muted small" t-field="product.description_sale"/>
</div>
<t t-call="website_sale.variants">
<t t-set="combination" t-value="product._get_first_possible_combination(parent_combination)"/>
</t>
<t t-call="website_sale.variants"/>
</td>
<td class="text-center td-qty d-none">
<t t-call='website_sale_product_configurator.product_quantity_config' />
@@ -206,16 +153,10 @@
<td class="text-center td-price">
<div t-attf-class="text-danger oe_default_price oe_optional oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
t-out="combination_info['list_price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product.env.company).currency_id
}'/>
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<div class="oe_price" style="white-space: nowrap;"
t-out="combination_info['price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product.env.company).currency_id
}'/>
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span class="js_raw_price d-none" t-out="combination_info['price']" />
<p class="css_not_available_msg alert alert-warning">Option not available</p>
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="configure_optional_products_website" inherit_id="website_sale_product_configurator.configure_optional_products">
<xpath expr="//th[hasclass('td-qty')]/span[hasclass('label')]" position="attributes">
<attribute name='t-if'>not request.is_frontend or (request.is_frontend and is_view_active('website_sale.product_quantity'))</attribute>
</xpath>
</template>
<template id="product_quantity_config_website" inherit_id="website_sale_product_configurator.product_quantity_config" priority="18">
<xpath expr="//div[hasclass('css_quantity')]" position="attributes">
<attribute name='t-if'>not request.is_frontend or (request.is_frontend and is_view_active('website_sale.product_quantity'))</attribute>
</xpath>
<xpath expr="//div[hasclass('css_quantity')]" position="after">
<input t-else="" type="hidden" class="d-none js_quantity form-control quantity" name="add_qty" t-att-value="add_qty or 1"/>
</xpath>
</template>
</odoo>
@@ -25,9 +25,7 @@ class WebsiteSaleSlides(WebsiteSlides):
# search the product to apply ACLs, notably on published status, to avoid access errors
product = request.env['product.product'].search([('id', '=', channel.product_id.id)]) if channel.product_id else request.env['product.product']
if product:
pricelist = request.website.get_current_pricelist()
values['product_info'] = channel.product_id.product_tmpl_id._get_combination_info(product_id=channel.product_id.id, pricelist=pricelist)
values['product_info']['currency_id'] = request.website.currency_id
values['product_info'] = product._get_combination_info_variant()
else:
values['product_info'] = False
return values
@@ -5,8 +5,8 @@
<template id="lesson_content_quiz" inherit_id="website_slides.lesson_content_quiz">
<xpath expr="//div[hasclass('o_wslides_js_lesson_quiz')]" position="attributes">
<attribute name="t-att-data-price">product_info['price'] if product_info else None</attribute>
<attribute name="t-att-data-currency-name">product_info['currency_id'].name if product_info else None</attribute>
<attribute name="t-att-data-currency-symbol">product_info['currency_id'].symbol if product_info else None</attribute>
<attribute name="t-att-data-currency-name">product_info['currency'].name if product_info else None</attribute>
<attribute name="t-att-data-currency-symbol">product_info['currency'].symbol if product_info else None</attribute>
<attribute name="t-att-data-has-discounted-price">product_info['has_discounted_price'] if product_info else None</attribute>
<attribute name="t-att-data-product-id">slide.channel_id.product_id.id if slide.channel_id.product_id else None</attribute>
<attribute name="t-att-data-list-price">product_info['list_price'] if product_info else None</attribute>
@@ -58,8 +58,8 @@
<template id="slide_fullscreen" inherit_id="website_slides.slide_fullscreen">
<xpath expr="//div[hasclass('o_wslides_fs_main')]" position="attributes">
<attribute name="t-att-data-price">product_info['price'] if product_info else None</attribute>
<attribute name="t-att-data-currency-name">product_info['currency_id'].name if product_info else None</attribute>
<attribute name="t-att-data-currency-symbol">product_info['currency_id'].symbol if product_info else None</attribute>
<attribute name="t-att-data-currency-name">product_info['currency'].name if product_info else None</attribute>
<attribute name="t-att-data-currency-symbol">product_info['currency'].symbol if product_info else None</attribute>
<attribute name="t-att-data-has-discounted-price">product_info['has_discounted_price'] if product_info else None</attribute>
<attribute name="t-att-data-product-id">slide.channel_id.product_id.id if product_info else None</attribute>
<attribute name="t-att-data-list-price">product_info['list_price'] if product_info else None</attribute>
@@ -112,17 +112,17 @@
<!-- real price -->
<div class="oe_price fw-bold text-nowrap h2 my-2"
t-esc="product_info['price']"
t-options="{'widget': 'monetary', 'display_currency': product_info['currency_id']}"/>
t-options="{'widget': 'monetary', 'display_currency': product_info['currency']}"/>
<span itemprop="price" style="display:none;" t-esc="product_info['price']"/>
<span itemprop="priceCurrency" style="display:none;" t-esc="product_info['currency_id'].name"/>
<span itemprop="priceCurrency" style="display:none;" t-esc="product_info['currency'].name"/>
<!-- original discounted price, if any -->
<del t-att-class="'text-600 text-nowrap oe_default_price %s' % ('' if product_info['has_discounted_price'] else 'd-none')"
t-esc="product_info['list_price']"
t-options="{'widget': 'monetary', 'display_currency': product_info['currency_id']}"/>
t-options="{'widget': 'monetary', 'display_currency': product_info['currency']}"/>
</div>
<div class="css_non_editable_mode_hidden decimal_precision oe_price fw-bold text-nowrap h2 my-2"
t-att-data-precision="str(product_info['currency_id'].decimal_places)">
<span t-field="channel.product_id.list_price" t-options="{'widget': 'monetary', 'display_currency': product_info['currency_id']}"/>
t-att-data-precision="str(product_info['currency'].decimal_places)">
<span t-field="channel.product_id.list_price" t-options="{'widget': 'monetary', 'display_currency': product_info['currency']}"/>
</div>
</div>
<t t-if="not invite_preview and channel.prerequisite_channel_ids and not channel.prerequisite_user_has_completed">
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request, route
from odoo.addons.website_sale.controllers.variant import WebsiteSaleVariantController
class WebsiteSaleStockVariantController(WebsiteSaleVariantController):
@http.route()
def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **kw):
kw['context'] = kw.get('context') or {} # context may be passed as None in some cases
kw['context'].update(website_sale_stock_get_quantity=True)
return super(WebsiteSaleStockVariantController, self).get_combination_info_website(product_template_id, product_id, combination, add_qty, **kw)
@route()
def get_combination_info_website(self, *args, **kwargs):
request.update_context(website_sale_stock_get_quantity=True)
return super().get_combination_info_website(*args, **kwargs)
@@ -25,8 +25,11 @@ class ProductProduct(models.Model):
return 0
def _is_sold_out(self):
combination_info = self.with_context(website_sale_stock_get_quantity=True).product_tmpl_id._get_combination_info(product_id=self.id)
return combination_info['product_type'] == 'product' and combination_info['free_qty'] <= 0
self.ensure_one()
if not self.type == 'product':
return False
free_qty = self.env['website'].get_current_website()._get_product_available_qty(self)
return free_qty <= 0
def _website_show_quick_add(self):
return (self.allow_out_of_stock_order or not self._is_sold_out()) and super()._website_show_quick_add()
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo.tools.translate import html_translate
from odoo.http import request
from odoo.tools.translate import html_translate
class ProductTemplate(models.Model):
@@ -14,51 +14,43 @@ class ProductTemplate(models.Model):
show_availability = fields.Boolean(string='Show availability Qty', default=False)
out_of_stock_message = fields.Html(string="Out-of-Stock Message", translate=html_translate)
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
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)
def _is_sold_out(self):
return self.type == 'product' and self.product_variant_id._is_sold_out()
if not self.env.context.get('website_sale_stock_get_quantity'):
return combination_info
def _website_show_quick_add(self):
return (self.allow_out_of_stock_order or not self._is_sold_out()) and super()._website_show_quick_add()
if combination_info['product_id']:
product = self.env['product.product'].sudo().browse(combination_info['product_id'])
website = self.env['website'].get_current_website()
free_qty = product.with_context(warehouse=website._get_warehouse_available()).free_qty
has_stock_notification = product._has_stock_notification(self.env.user.partner_id) \
or request \
and product.id in request.session.get('product_with_stock_notification_enabled',
set())
def _get_additionnal_combination_info(self, product_or_template, quantity, date, website):
res = super()._get_additionnal_combination_info(product_or_template, quantity, date, website)
product_or_template = product_or_template.sudo()
res.update({
'product_type': product_or_template.type,
'allow_out_of_stock_order': product_or_template.allow_out_of_stock_order,
'available_threshold': product_or_template.available_threshold,
})
if product_or_template.is_product_variant:
product = product_or_template
free_qty = website._get_product_available_qty(product)
has_stock_notification = (
product._has_stock_notification(self.env.user.partner_id)
or request and product.id in request.session.get(
'product_with_stock_notification_enabled', set())
)
stock_notification_email = request and request.session.get('stock_notification_email', '')
combination_info.update({
res.update({
'free_qty': free_qty,
'product_type': product.type,
'product_template': self.id,
'available_threshold': self.available_threshold,
'cart_qty': product._get_cart_qty(website),
'uom_name': product.uom_id.name,
'allow_out_of_stock_order': self.allow_out_of_stock_order,
'show_availability': self.show_availability,
'out_of_stock_message': self.out_of_stock_message,
'show_availability': product_or_template.show_availability,
'out_of_stock_message': product_or_template.out_of_stock_message,
'has_stock_notification': has_stock_notification,
'stock_notification_email': stock_notification_email,
})
else:
product_template = self.sudo()
combination_info.update({
res.update({
'free_qty': 0,
'product_type': product_template.type,
'allow_out_of_stock_order': product_template.allow_out_of_stock_order,
'available_threshold': product_template.available_threshold,
'product_template': product_template.id,
'cart_qty': 0,
})
return combination_info
def _is_sold_out(self):
return self.product_variant_id._is_sold_out()
def _website_show_quick_add(self):
return (self.allow_out_of_stock_order or not self._is_sold_out()) and super()._website_show_quick_add()
return res
@@ -102,11 +102,12 @@ class SaleOrder(models.Model):
)
def _all_product_available(self):
self.ensure_one()
for line in self.with_context(website_sale_stock_get_quantity=True).order_line:
product = line.product_id
if product.type != 'product' or product.allow_out_of_stock_order:
continue
combination_info = product._get_combination_info_variant(add_qty=line.product_uom_qty)
if combination_info['free_qty'] == 0:
free_qty = self.website_id._get_product_available_qty(product)
if free_qty == 0:
return False
return True
+6 -2
View File
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class Website(models.Model):
@@ -27,3 +28,6 @@ class Website(models.Model):
def sale_get_order(self, *args, **kwargs):
so = super().sale_get_order(*args, **kwargs)
return so.with_context(warehouse=so.warehouse_id.id) if so else so
def _get_product_available_qty(self, product):
return product.with_context(warehouse=self._get_warehouse_available()).free_qty
@@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.website.tools import MockRequest
from odoo.addons.sale.tests.test_sale_product_attribute_value_config import TestSaleProductAttributeValueCommon
from odoo.tests import tagged
from odoo.addons.sale.tests.test_sale_product_attribute_value_config import TestSaleProductAttributeValueCommon
from odoo.addons.website.tools import MockRequest
@tagged('post_install', '-at_install')
class TestWebsiteSaleStockProductWarehouse(TestSaleProductAttributeValueCommon):
@@ -64,22 +65,28 @@ class TestWebsiteSaleStockProductWarehouse(TestSaleProductAttributeValueCommon):
When the user doesn't set any warehouse, the module should still select
a default one.
"""
test_env = self.env['base'].with_context(
website_id=self.website.id,
website_sale_stock_get_quantity=True,
).env
for wh, qty_a, qty_b in [(self.warehouse_1, 10, 0), (self.warehouse_2, 15, 10), (False, 10, 0)]:
# set warehouse_id
self.website.warehouse_id = wh
product = self.product_A.with_context(website_id=self.website.id)
combination_info = product.product_tmpl_id.with_context(website_sale_stock_get_quantity=True)._get_combination_info()
combination_info = self.product_A.with_env(test_env)._get_combination_info_variant()
# Check available quantity of product is according to warehouse
self.assertEqual(combination_info['free_qty'], qty_a, "%s units of Product A should be available in warehouse %s" % (qty_a, wh))
self.assertEqual(
combination_info['free_qty'], qty_a,
f"{qty_a} units of Product A should be available in warehouse {wh}")
product = self.product_B.with_context(website_id=self.website.id)
combination_info = product.product_tmpl_id.with_context(website_sale_stock_get_quantity=True)._get_combination_info()
combination_info = self.product_B.with_env(test_env)._get_combination_info_variant()
# Check available quantity of product is according to warehouse
self.assertEqual(combination_info['free_qty'], qty_b, "%s units of Product B should be available in warehouse %s" % (qty_b, wh))
self.assertEqual(
combination_info['free_qty'], qty_b,
f"{qty_b} units of Product B should be available in warehouse {wh}")
def test_02_update_cart_with_multi_warehouses(self):
""" When the user updates his cart and increases a product quantity, if
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request, route
from odoo.addons.website_sale.controllers.variant import WebsiteSaleVariantController
class WebsiteSaleStockWishlistVariantController(WebsiteSaleVariantController):
@http.route()
def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **kw):
kw['context'] = kw.get('context') or {} # context may be passed as None in some cases
kw['context'].update(website_sale_stock_wishlist_get_wish=True)
return super().get_combination_info_website(product_template_id, product_id, combination, add_qty, **kw)
@route()
def get_combination_info_website(self, *args, **kwargs):
request.update_context(website_sale_stock_wishlist_get_wish=True)
return super().get_combination_info_website(*args, **kwargs)
@@ -1,25 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ProductTemplate(models.Model):
_inherit = "product.template"
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
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,
)
class ProductTemplate(models.Model):
_inherit = 'product.template'
def _get_additionnal_combination_info(self, product_or_template, quantity, date, website):
res = super()._get_additionnal_combination_info(product_or_template, quantity, date, website)
if not self.env.context.get('website_sale_stock_wishlist_get_wish'):
return combination_info
return res
if combination_info['product_id']:
product = self.env['product.product'].sudo().browse(combination_info["product_id"])
combination_info['is_in_wishlist'] = product._is_in_wishlist()
if product_or_template.is_product_variant:
product_sudo = product_or_template.sudo()
res['is_in_wishlist'] = product_sudo._is_in_wishlist()
return combination_info
return res
@@ -14,7 +14,7 @@ class WebsiteSaleWishlist(WebsiteSale):
pricelist = website.pricelist_id
product = request.env['product.product'].browse(product_id)
price = product._get_combination_info_variant(pricelist=website.pricelist_id)['price']
price = product._get_combination_info_variant()['price']
Wishlist = request.env['product.wishlist']
if request.website.is_public_user():
@@ -176,8 +176,10 @@
<body>
<t t-foreach="wishes" t-as="wish">
<t t-set="combination_info" t-value="wish.product_id._get_combination_info_variant()"/>
<tr t-att-data-wish-id='wish.id' t-att-data-product-id='wish.product_id.id'
t-att-data-product-tracking-info="json.dumps(request.env['product.template'].get_google_analytics_data(combination_info))">
<tr t-att-data-wish-id="wish.id"
t-att-data-product-id="wish.product_id.id"
t-att-data-product-tracking-info="'product_tracking_info' in combination_info and json.dumps(combination_info['product_tracking_info'])"
>
<td class='td-img align-middle'>
<a t-att-href="wish.product_id.website_url">
<img t-attf-src="/web/image/product.product/#{wish.product_id.id}/image_128" class="img img-fluid" style="margin:auto;" alt="Product image"/>
@@ -192,7 +194,8 @@
<span t-field="website.prevent_zero_price_sale_text"/>
</td>
<td class="align-middle o_wish_price" t-else="">
<t t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<t t-out="combination_info['price']"
t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<small t-if="combination_info['base_unit_price']" class="cart_product_base_unit_price d-block text-muted" groups="website_sale.group_show_uom_price">
<t t-call='website_sale.base_unit_price'><t t-set='product' t-value='wish.product_id' /></t>
</small>