[MERGE][REF] sale,website_sale*: combination info logic

Clean, factorize and optimize the code related to product variants display and price computation, especially the whole `combination_info` logic, which can now be moved to `website_sale` since the new product configurator used for backend doesn't rely on it anymore.

Enterprise PR: https://github.com/odoo/enterprise/pull/41308

closes odoo/odoo#121986

Related: odoo/enterprise#41308
Related: odoo/upgrade#5134
Signed-off-by: Victor Feyens (vfe) <vfe@odoo.com>
This commit is contained in:
Odoo's Mergebot
2023-09-14 18:09:48 +00:00
committed by Odoo Online
48 changed files with 932 additions and 1198 deletions
-28
View File
@@ -1322,34 +1322,6 @@ class ProductTemplate(models.Model):
return _("There are no possible combination.")
combination = combination[:-1]
def _get_current_company(self, **kwargs):
"""Get the most appropriate company for this product.
If the company is set on the product, directly return it. Otherwise,
fallback to a contextual company.
:param kwargs: kwargs forwarded to the fallback method.
:return: the most appropriate company for this product
:rtype: recordset of one `res.company`
"""
self.ensure_one()
return self.company_id or self._get_current_company_fallback(**kwargs)
def _get_current_company_fallback(self, **kwargs):
"""Fallback to get the most appropriate company for this product.
This should only be called from `_get_current_company` but is defined
separately to allow override.
The final fallback will be the current user's company.
:return: the fallback company for this product
:rtype: recordset of one `res.company`
"""
self.ensure_one()
return self.env.company
def _get_placeholder_filename(self, field):
image_fields = ['image_%s' % size for size in [1920, 1024, 512, 256, 128]]
if field in image_fields:
+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'])
+25 -208
View File
@@ -1,48 +1,47 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from odoo import api, fields, models, _
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
from odoo.exceptions import ValidationError
from odoo.tools.float_utils import float_round
_logger = logging.getLogger(__name__)
class ProductTemplate(models.Model):
_inherit = 'product.template'
service_type = fields.Selection(
[('manual', 'Manually set quantities on order')], string='Track Service',
selection=[('manual', "Manually set quantities on order")],
string="Track Service",
compute='_compute_service_type', store=True, readonly=False, precompute=True,
help="Manually set quantities on order: Invoice based on the manually entered quantity, without creating an analytic account.\n"
"Timesheets on contract: Invoice based on the tracked hours on the related timesheet.\n"
"Create a task and track hours: Create a task on the sales order validation and track the work hours.")
sale_line_warn = fields.Selection(WARNING_MESSAGE, 'Sales Order Line', help=WARNING_HELP, required=True, default="no-message")
sale_line_warn_msg = fields.Text('Message for Sales Order Line')
sale_line_warn = fields.Selection(
WARNING_MESSAGE, string="Sales Order Line",
help=WARNING_HELP, required=True, default="no-message")
sale_line_warn_msg = fields.Text(string="Message for Sales Order Line")
expense_policy = fields.Selection(
[('no', 'No'),
('cost', 'At cost'),
('sales_price', 'Sales price')
], string='Re-Invoice Expenses', default='no',
selection=[
('no', "No"),
('cost', "At cost"),
('sales_price', "Sales price"),
],
string="Re-Invoice Expenses", default='no',
compute='_compute_expense_policy', store=True, readonly=False,
help="Validated expenses and vendor bills can be re-invoiced to a customer at its cost or sales price.")
visible_expense_policy = fields.Boolean("Re-Invoice Policy visible", compute='_compute_visible_expense_policy')
sales_count = fields.Float(compute='_compute_sales_count', string='Sold', digits='Product Unit of Measure')
visible_qty_configurator = fields.Boolean("Quantity visible in configurator", compute='_compute_visible_qty_configurator')
visible_expense_policy = fields.Boolean(
string="Re-Invoice Policy visible", compute='_compute_visible_expense_policy')
sales_count = fields.Float(
string="Sold", compute='_compute_sales_count', digits='Product Unit of Measure')
invoice_policy = fields.Selection(
[('order', 'Ordered quantities'),
('delivery', 'Delivered quantities')], string='Invoicing Policy',
selection=[
('order', "Ordered quantities"),
('delivery', "Delivered quantities"),
],
string="Invoicing Policy",
compute='_compute_invoice_policy', store=True, readonly=False, precompute=True,
help='Ordered Quantity: Invoice quantities ordered by the customer.\n'
'Delivered Quantity: Invoice quantities delivered to the customer.')
def _compute_visible_qty_configurator(self):
for product_template in self:
product_template.visible_qty_configurator = True
help="Ordered Quantity: Invoice quantities ordered by the customer.\n"
"Delivered Quantity: Invoice quantities delivered to the customer.")
@api.depends('name')
def _compute_visible_expense_policy(self):
@@ -80,7 +79,7 @@ class ProductTemplate(models.Model):
'shared product.') % (target_company.name, ', '.join(used_products)))
def action_view_sales(self):
action = self.env["ir.actions.actions"]._for_xml_id("sale.report_all_channels_sales_action")
action = self.env['ir.actions.actions']._for_xml_id('sale.report_all_channels_sales_action')
action['domain'] = [('product_tmpl_id', 'in', self.ids)]
action['context'] = {
'pivot_measures': ['product_uom_qty'],
@@ -91,42 +90,6 @@ class ProductTemplate(models.Model):
}
return action
def create_product_variant(self, product_template_attribute_value_ids):
""" Create if necessary and possible and return the id of the product
variant matching the given combination for this template.
Note AWA: Known "exploit" issues with this method:
- This method could be used by an unauthenticated user to generate a
lot of useless variants. Unfortunately, after discussing the
matter with ODO, there's no easy and user-friendly way to block
that behavior.
We would have to use captcha/server actions to clean/... that
are all not user-friendly/overkill mechanisms.
- This method could be used to try to guess what product variant ids
are created in the system and what product template ids are
configured as "dynamic", but that does not seem like a big deal.
The error messages are identical on purpose to avoid giving too much
information to a potential attacker:
- returning 0 when failing
- returning the variant id whether it already existed or not
:param product_template_attribute_value_ids: the combination for which
to get or create variant
:type product_template_attribute_value_ids: list of id
of `product.template.attribute.value`
:return: id of the product variant matching the combination or 0
:rtype: int
"""
combination = self.env['product.template.attribute.value'] \
.browse(product_template_attribute_value_ids)
return self._create_product_variant(combination, log_warning=True).id or 0
@api.onchange('type')
def _onchange_type(self):
res = super(ProductTemplate, self)._onchange_type()
@@ -151,153 +114,7 @@ class ProductTemplate(models.Model):
if self.env.context.get('sale_multi_pricelist_product_template'):
if self.user_has_groups('product.group_sale_pricelist'):
return [{
'label': _('Import Template for Products'),
'label': _("Import Template for Products"),
'template': '/product/static/xls/product_template.xls'
}]
return res
def _get_combination_info(
self, combination=False, product_id=False, add_qty=1, pricelist=False,
parent_combination=False, only_template=False
):
""" Return info about a given combination.
Note: this method does not take into account whether the combination is
actually possible.
:param combination: recordset of `product.template.attribute.value`
:param product_id: id of a `product.product`. If no `combination`
is set, the method will try to load the variant `product_id` if
it exists instead of finding a variant based on the combination.
If there is no combination, that means we definitely want a
variant and not something that will have no_variant set.
:param add_qty: float with the quantity for which to get the info,
indeed some pricelist rules might depend on it.
:param pricelist: `product.pricelist` the pricelist to use
(can be none, eg. from SO if no partner and no pricelist selected)
:param parent_combination: if no combination and no product_id are
given, it will try to find the first possible combination, taking
into account parent_combination (if set) for the exclusion rules.
:param only_template: boolean, if set to True, get the info for the
template only: ignore combination and don't try to find variant
:return: dict with product/combination info:
- product_id: the variant id matching the combination (if it exists)
- product_template_id: the current template id
- display_name: the name of the combination
- price: the computed price of the combination, take the catalog
price if no pricelist is given
- list_price: the catalog price of the combination, but this is
not the "real" list_price, it has price_extra included (so
it's actually more closely related to `lst_price`), and it
is converted to the pricelist currency (if given)
- has_discounted_price: True if the pricelist discount policy says
the price does not include the discount and there is actually a
discount applied (price < list_price), else False
"""
self.ensure_one()
quantity = self.env.context.get('quantity', add_qty)
product_template = self
combination = combination or product_template.env['product.template.attribute.value']
pricelist = pricelist or self.env['product.pricelist']
if not product_id and not combination and not only_template:
combination = product_template._get_first_possible_combination(parent_combination)
if only_template:
product = product_template.env['product.product']
elif product_id and not combination:
product = product_template.env['product.product'].browse(product_id)
else:
product = product_template._get_variant_for_combination(combination)
if product:
display_image = bool(product.image_128)
display_name = product.display_name
else:
display_image = bool(product_template.image_128)
display_name = product_template.display_name
combination_name = combination._get_combination_name()
if combination_name:
display_name = "%s (%s)" % (display_name, combination_name)
product_or_template = product or self
price_context = product_or_template._get_product_price_context(combination)
product_or_template = product_or_template.with_context(**price_context)
list_price = product_or_template._price_compute('list_price')[product_or_template.id]
price_extra = product_or_template._get_attributes_extra_price()
price = pricelist._get_product_price(product_or_template, quantity)
currency = pricelist.currency_id or self.env.company.currency_id
if currency != product_template.currency_id:
list_price = product_template.currency_id._convert(
list_price, currency, product_template._get_current_company(pricelist=pricelist),
fields.Date.today()
)
price_extra = product_template.currency_id._convert(
price_extra, currency, product_template._get_current_company(pricelist=pricelist),
fields.Date.today()
)
price_without_discount = list_price if pricelist and pricelist.discount_policy == 'without_discount' else price
has_discounted_price = currency.compare_amounts(price_without_discount, price) == 1
return {
'product_id': product.id,
'product_template_id': product_template.id,
'display_name': display_name,
'display_image': display_image,
'price': price,
'list_price': list_price,
'price_extra': price_extra,
'has_discounted_price': has_discounted_price,
}
def _can_be_added_to_cart(self):
"""
Pre-check to `_is_add_to_cart_possible` to know if product can be sold.
"""
return self.sale_ok
def _is_add_to_cart_possible(self, parent_combination=None):
"""
It's possible to add to cart (potentially after configuration) if
there is at least one possible combination.
:param parent_combination: the combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: True if it's possible to add to cart, else False
:rtype: bool
"""
self.ensure_one()
if not self.active or not self._can_be_added_to_cart():
# for performance: avoid calling `_get_possible_combinations`
return False
return next(self._get_possible_combinations(parent_combination), False) is not False
def _get_current_company_fallback(self, **kwargs):
"""Override: if a pricelist is given, fallback to the company of the
pricelist if it is set, otherwise use the one from parent method."""
res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs)
pricelist = kwargs.get('pricelist')
return pricelist and pricelist.company_id or res
@@ -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})
@@ -9,13 +9,6 @@ from odoo.addons.website_event.controllers.main import WebsiteEventController
class WebsiteEventBoothController(WebsiteEventController):
@route()
def event_booth_main(self, event, booth_category_id=False, booth_ids=False):
pricelist = request.website.pricelist_id
if pricelist:
event = event.with_context(pricelist=pricelist.id)
return super(WebsiteEventBoothController, self).event_booth_main(event, booth_category_id, booth_ids)
@route()
def event_booth_registration_confirm(self, event, booth_category_id, event_booth_ids, **kwargs):
"""Override: Doesn't call the parent method because we go through the checkout
@@ -10,14 +10,6 @@ from odoo.addons.website_event.controllers.main import WebsiteEventController
class WebsiteEventSaleController(WebsiteEventController):
@route()
def event_register(self, event, **post):
if not request.context.get('pricelist'):
pricelist = request.website.pricelist_id
if pricelist:
event = event.with_context(pricelist=pricelist.id)
return super().event_register(event, **post)
def _process_tickets_form(self, event, form_details):
""" Add price information on ticket order """
res = super()._process_tickets_form(event, form_details)
+26 -17
View File
@@ -328,14 +328,20 @@ class WebsiteSale(http.Controller):
keep = QueryURL('/shop', **self._shop_get_query_url_kwargs(category and int(category), search, min_price, max_price, **post))
now = datetime.timestamp(datetime.now())
pricelist = website.get_current_pricelist()
if request.session.get('website_sale_pricelist_time', 0) < now - 60*60: # test: 1 hour in session
pricelist = website.pricelist_id
pricelist = website.pricelist_id
if 'website_sale_pricelist_time' in request.session:
# Check if we need to refresh the cached pricelist
pricelist_save_time = request.session['website_sale_pricelist_time']
if pricelist_save_time < now - 60*60:
request.session.pop('website_sale_current_pl', None)
website.invalidate_recordset(['pricelist_id'])
pricelist = website.pricelist_id
request.session['website_sale_pricelist_time'] = now
request.session['website_sale_current_pl'] = pricelist.id
else:
request.session['website_sale_pricelist_time'] = now
request.session['website_sale_current_pl'] = pricelist.id
request.update_context(pricelist=pricelist.id, partner=request.env.user.partner_id)
filter_by_price_enabled = website.is_view_active('website_sale.filter_products_price')
if filter_by_price_enabled:
company_currency = website.company_id.currency_id
@@ -344,9 +350,9 @@ class WebsiteSale(http.Controller):
else:
conversion_rate = 1
url = "/shop"
url = '/shop'
if search:
post["search"] = search
post['search'] = search
if attrib_list:
post['attrib'] = attrib_list
@@ -440,7 +446,9 @@ class WebsiteSale(http.Controller):
layout_mode = 'grid'
request.session['website_sale_shop_layout_mode'] = layout_mode
products_prices = lazy(lambda: products._get_sales_prices(pricelist))
# Try to fetch geoip based fpos or fallback on partner one
fiscal_position_sudo = website.fiscal_position_id.sudo()
products_prices = lazy(lambda: products._get_sales_prices(pricelist, fiscal_position_sudo))
values = {
'search': fuzzy_search_term or search,
@@ -451,6 +459,7 @@ class WebsiteSale(http.Controller):
'attrib_set': attrib_set,
'pager': pager,
'pricelist': pricelist,
'fiscal_position': fiscal_position_sudo,
'add_qty': add_qty,
'products': products,
'search_product': search_product,
@@ -539,8 +548,7 @@ class WebsiteSale(http.Controller):
combination = request.env['product.template.attribute.value'].browse(combination_ids)
product_product = product_template._get_variant_for_combination(combination)
if not product_product:
product_product = request.env['product.product'].browse(
product_template.create_product_variant(combination_ids))
product_product = product_template._create_product_variant(combination)
if product_template.has_configurable_attributes and product_product:
product_product.write({
'product_variant_image_ids': image_create_data
@@ -674,7 +682,7 @@ class WebsiteSale(http.Controller):
return {
'search': search,
'category': category,
'pricelist': request.website.get_current_pricelist(),
'pricelist': request.website.pricelist_id,
'attrib_values': attrib_values,
'attrib_set': attrib_set,
'keep': keep,
@@ -697,7 +705,7 @@ class WebsiteSale(http.Controller):
min_price = args.get('min_price')
max_price = args.get('max_price')
if min_price or max_price:
previous_price_list = request.website.get_current_pricelist()
previous_price_list = request.website.pricelist_id
try:
min_price = float(min_price)
args['min_price'] = min_price and str(
@@ -1881,15 +1889,15 @@ class CustomerPortal(sale_portal.CustomerPortal):
return {}
@http.route('/my/orders/reorder_modal_content', type='json', auth='public', website=True)
def _get_saleorder_reorder_content_modal(self, order_id, access_token):
def my_orders_reorder_modal_content(self, order_id, access_token):
try:
sale_order = self._document_check_access('sale.order', order_id, access_token=access_token)
except (AccessError, MissingError):
return request.redirect('/my')
pricelist = request.env['website'].get_current_website().get_current_pricelist()
currency = request.env['website'].get_current_website().currency_id
result = {
'currency': pricelist.currency_id.id,
'currency': currency.id,
'products': [],
}
for line in sale_order.order_line:
@@ -1921,8 +1929,9 @@ class CustomerPortal(sale_portal.CustomerPortal):
'has_image': bool(line.product_id.image_128),
}
if res['add_to_cart_allowed']:
res['combinationInfo'] = line.product_id.product_tmpl_id.with_context(**self._sale_reorder_get_line_context())\
._get_combination_info(combination, res['product_id'], res['qty'], pricelist)
res['combinationInfo'] = line.product_id.product_tmpl_id.with_context(
**self._sale_reorder_get_line_context()
)._get_combination_info(combination, res['product_id'], res['qty'])
else:
res['combinationInfo'] = {}
result['products'].append(res)
+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))
+2
View File
@@ -15,6 +15,8 @@ from . import product_public_category
from . import product_ribbon
from . import product_tag
from . import product_template
from . import product_template_attribute_line
from . import product_template_attribute_value
from . import res_company
from . import res_config_settings
from . import res_country
@@ -1,32 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import OrderedDict
from odoo import models, fields
class ProductAttribute(models.Model):
_inherit = 'product.attribute'
visibility = fields.Selection([('visible', 'Visible'), ('hidden', 'Hidden')], default='visible')
class ProductTemplateAttributeLine(models.Model):
_inherit = 'product.template.attribute.line'
def _prepare_single_value_for_display(self):
"""On the product page group together the attribute lines that concern
the same attribute and that have only one value each.
Indeed those are considered informative values, they do not generate
choice for the user, so they are displayed below the configurator.
The returned attributes are ordered as they appear in `self`, so based
on the order of the attribute lines.
"""
single_value_lines = self.filtered(lambda ptal: len(ptal.value_ids) == 1)
single_value_attributes = OrderedDict([(pa, self.env['product.template.attribute.line']) for pa in single_value_lines.attribute_id])
for ptal in single_value_lines:
single_value_attributes[ptal.attribute_id] |= ptal
return single_value_attributes
visibility = fields.Selection(
selection=[('visible', "Visible"), ('hidden', "Hidden")],
default='visible')
@@ -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()
+294 -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,298 @@ class ProductTemplate(models.Model):
return res
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
"""Override for website, where we want to:
- take the website pricelist if no pricelist is set and one is available
- apply the b2b/b2c setting to the result
def _can_be_added_to_cart(self):
"""
Pre-check to `_is_add_to_cart_possible` to know if product can be sold.
"""
return self.sale_ok
This will work when adding website_id to the context, which is done
automatically when called from routes with website=True.
def _is_add_to_cart_possible(self, parent_combination=None):
"""
It's possible to add to cart (potentially after configuration) if
there is at least one possible combination.
:param parent_combination: the combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: True if it's possible to add to cart, else False
:rtype: bool
"""
self.ensure_one()
if not self.active or not self._can_be_added_to_cart():
# for performance: avoid calling `_get_possible_combinations`
return False
return next(self._get_possible_combinations(parent_combination), False) is not False
def _get_combination_info(
self, combination=False, product_id=False, add_qty=1.0,
parent_combination=False, only_template=False,
):
""" Return info about a given combination.
Note: this method does not take into account whether the combination is
actually possible.
:param combination: recordset of `product.template.attribute.value`
:param int product_id: `product.product` id. If no `combination`
is set, the method will try to load the variant `product_id` if
it exists instead of finding a variant based on the combination.
If there is no combination, that means we definitely want a
variant and not something that will have no_variant set.
:param float add_qty: the quantity for which to get the info,
indeed some pricelist rules might depend on it.
:param parent_combination: if no combination and no product_id are
given, it will try to find the first possible combination, taking
into account parent_combination (if set) for the exclusion rules.
:param only_template: boolean, if set to True, get the info for the
template only: ignore combination and don't try to find variant
:return: dict with product/combination info:
- product_id: the variant id matching the combination (if it exists)
- product_template_id: the current template id
- display_name: the name of the combination
- price: the computed price of the combination, take the catalog
price if no pricelist is given
- price_extra: the computed extra price of the combination
- list_price: the catalog price of the combination, but this is
not the "real" list_price, it has price_extra included (so
it's actually more closely related to `lst_price`), and it
is converted to the pricelist currency (if given)
- has_discounted_price: True if the pricelist discount policy says
the price does not include the discount and there is actually a
discount applied (price < list_price), else False
"""
self.ensure_one()
current_website = self.env['website'].get_current_website()
combination = combination or self.env['product.template.attribute.value']
parent_combination = parent_combination or self.env['product.template.attribute.value']
website = self.env['website'].get_current_website().with_context(self.env.context)
if self.env.context.get('website_id'):
if not pricelist:
pricelist = current_website.get_current_pricelist()
if not product_id and not combination and not only_template:
combination = self._get_first_possible_combination(parent_combination)
combination_info = super(ProductTemplate, self)._get_combination_info(
combination=combination, product_id=product_id, add_qty=add_qty, pricelist=pricelist,
parent_combination=parent_combination, only_template=only_template)
if only_template:
product = self.env['product.product']
elif product_id:
product = self.env['product.product'].browse(product_id)
if (combination - product.product_template_attribute_value_ids):
# If the combination is not fully represented in the given product
# make sure to fetch the right product for the given combination
product = self._get_variant_for_combination(combination)
else:
product = self._get_variant_for_combination(combination)
product = self.env['product.product'].browse(combination_info['product_id'])
product_or_template = product or self
combination = combination or product.product_template_attribute_value_ids
if current_website.is_view_active('website_sale.product_tags'):
combination_info['product_tags'] = self.env['ir.ui.view']._render_template(
'website_sale.product_tags', values={
'all_product_tags': product.all_product_tag_ids.filtered('visible_on_ecommerce')
}
display_name = product_or_template.display_name
if not product:
combination_name = combination._get_combination_name()
if combination_name:
display_name = f"{display_name} ({combination_name})"
price_context = product_or_template._get_product_price_context(combination)
product_or_template = product_or_template.with_context(**price_context)
combination_info = {
'product_id': product.id,
'product_template_id': self.id,
'display_name': display_name,
'display_image': bool(product_or_template.image_128),
'is_combination_possible': self._is_combination_possible(combination=combination, parent_combination=parent_combination),
'parent_exclusions': self._get_parent_attribute_exclusions(parent_combination=parent_combination),
**self._get_additionnal_combination_info(
product_or_template=product_or_template,
quantity=add_qty or 1.0,
date=fields.Date.context_today(self),
website=website,
)
}
if self.env.context.get('website_id'):
product = product or self
partner = self.env.user.partner_id
company_id = current_website.company_id
currency = current_website.currency_id
fpos_id = self.env['website'].sudo()._get_current_fiscal_position_id(partner)
fiscal_position = self.env['account.fiscal.position'].sudo().browse(fpos_id)
product_taxes = product.sudo().taxes_id.filtered_domain(self.env['account.tax']._check_company_domain(company_id))
taxes = fiscal_position.map_tax(product_taxes)
price = self._price_with_tax_computed(
combination_info['price'], product_taxes, taxes, company_id, currency, product,
partner
)
if pricelist.discount_policy == 'without_discount':
list_price = self._price_with_tax_computed(
combination_info['list_price'], product_taxes, taxes, company_id, currency,
product, partner
)
else:
list_price = price
price_extra = self._price_with_tax_computed(
combination_info['price_extra'], product_taxes, taxes, company_id, currency,
product, partner
)
base_unit_price = product._get_base_unit_price(list_price)
if currency != product.currency_id:
base_unit_price = product.currency_id._convert(
base_unit_price,
currency,
company_id,
fields.Date.today())
has_discounted_price = currency.compare_amounts(list_price, price) == 1
prevent_zero_price_sale = not price and current_website.prevent_zero_price_sale
compare_list_price = self.compare_list_price
if self.currency_id != pricelist.currency_id:
compare_list_price = self.currency_id._convert(self.compare_list_price, pricelist.currency_id, self.env.company,
fields.Datetime.now(), round=False)
combination_info.update(
base_unit_name=product.base_unit_name,
base_unit_price=product.base_unit_count and list_price / product.base_unit_count,
price=price,
list_price=list_price,
price_extra=price_extra,
has_discounted_price=has_discounted_price,
prevent_zero_price_sale=prevent_zero_price_sale,
compare_list_price=compare_list_price,
if website.google_analytics_key:
combination_info['product_tracking_info'] = self._get_google_analytics_data(
product,
combination_info,
)
return combination_info
def _price_with_tax_computed(
self, price, product_taxes, taxes, company_id, currency, product, partner
def _get_additionnal_combination_info(self, product_or_template, quantity, date, website):
"""Computes additional combination info, based on given parameters
:param product_or_template: `product.product` or `product.template` record
as variant values must take precedence over template values (when we have a variant)
:param float quantity:
:param date date: today's date, avoids useless calls to today/context_today and harmonize
behavior
:param website: `website` record holding the current website of the request (if any),
or the contextual website (tests, ...)
:returns: additional product/template information
:rtype: dict
"""
pricelist = website.pricelist_id
currency = website.currency_id
compare_list_price = product_or_template.compare_list_price
list_price = product_or_template._price_compute('list_price')[product_or_template.id]
price_extra = product_or_template._get_attributes_extra_price()
if product_or_template.currency_id != currency:
price_extra = self.currency_id._convert(
from_amount=price_extra,
to_currency=currency,
company=self.env.company,
date=date,
)
list_price = self.currency_id._convert(
from_amount=list_price,
to_currency=currency,
company=self.env.company,
date=date,
)
compare_list_price = product_or_template.currency_id._convert(
from_amount=compare_list_price,
to_currency=self.currency_id,
company=self.env.company,
date=date,
round=False)
# Pricelist price doesn't have to be converted
pricelist_price = pricelist._get_product_price(
product=product_or_template,
quantity=quantity,
target_currency=currency,
)
if pricelist.discount_policy == 'without_discount':
has_discounted_price = currency.compare_amounts(list_price, pricelist_price) == 1
else:
has_discounted_price = False
combination_info = {
'price_extra': price_extra,
'price': pricelist_price,
'list_price': list_price,
'has_discounted_price': has_discounted_price,
'compare_list_price': compare_list_price,
}
# Apply taxes
fiscal_position = website.fiscal_position_id.sudo()
product_taxes = product_or_template.sudo().taxes_id.filtered(
lambda t: t.company_id == self.env.company)
taxes = self.env['account.tax']
if product_taxes:
taxes = fiscal_position.map_tax(product_taxes)
# We do not apply taxes on the compare_list_price value because it's meant to be
# a strict value displayed as is.
for price_key in ('price', 'list_price', 'price_extra'):
combination_info[price_key] = self._apply_taxes_to_price(
combination_info[price_key],
currency,
product_taxes,
taxes,
product_or_template,
)
combination_info.update({
'prevent_zero_price_sale': website.prevent_zero_price_sale and float_is_zero(
combination_info['price'],
precision_rounding=currency.rounding,
),
'base_unit_name': product_or_template.base_unit_name,
'base_unit_price': product_or_template._get_base_unit_price(combination_info['price']),
# additional info to simplify overrides
'currency': currency, # displayed currency
'date': date,
'product_taxes': product_taxes, # taxes before fpos mapping
'taxes': taxes, # taxes after fpos mapping
})
if pricelist.discount_policy != 'without_discount':
# Leftover from before cleanup, different behavior between ecommerce & backend configurator
# probably to keep product sales price hidden from customers ?
combination_info['list_price'] = combination_info['price']
if website.is_view_active('website_sale.product_tags') and product_or_template.is_product_variant:
combination_info['product_tags'] = self.env['ir.ui.view']._render_template(
'website_sale.product_tags', values={
'all_product_tags': product_or_template.all_product_tag_ids.filtered('visible_on_ecommerce')
}
)
return combination_info
@api.model
def _apply_taxes_to_price(
self, price, currency, product_taxes, taxes, product_or_template,
):
# Ideally, we should use _get_tax_included_unit_price
# but it doesn't allow to request tax-excluded amount.
website = self.env['website'].get_current_website()
price = self.env['account.tax']._fix_tax_included_price_company(
price, product_taxes, taxes, company_id
price, product_taxes, taxes, self.env.company,
)
show_tax = website.show_line_subtotals_tax_selection
tax_display = 'total_excluded' if show_tax == 'tax_excluded' else 'total_included'
# The list_price is always the price of one.
return taxes.compute_all(price, currency, 1, product, partner)[tax_display]
return taxes.compute_all(
price, currency, 1, product_or_template, self.env.user.partner_id
)[tax_display]
def create_product_variant(self, product_template_attribute_value_ids):
""" Create if necessary and possible and return the id of the product
variant matching the given combination for this template.
Note AWA: Known "exploit" issues with this method:
- This method could be used by an unauthenticated user to generate a
lot of useless variants. Unfortunately, after discussing the
matter with ODO, there's no easy and user-friendly way to block
that behavior.
We would have to use captcha/server actions to clean/... that
are all not user-friendly/overkill mechanisms.
- This method could be used to try to guess what product variant ids
are created in the system and what product template ids are
configured as "dynamic", but that does not seem like a big deal.
The error messages are identical on purpose to avoid giving too much
information to a potential attacker:
- returning 0 when failing
- returning the variant id whether it already existed or not
:param product_template_attribute_value_ids: the combination for which
to get or create variant
:type product_template_attribute_value_ids: list of id
of `product.template.attribute.value`
:return: id of the product variant matching the combination or 0
:rtype: int
"""
combination = self.env['product.template.attribute.value'].browse(
product_template_attribute_value_ids)
return self._create_product_variant(combination, log_warning=True).id or 0
def _get_image_holder(self):
"""Returns the holder of the image to use as default representation.
@@ -349,14 +547,6 @@ class ProductTemplate(models.Model):
return 'image_512'
return 'image_1024'
def _get_current_company_fallback(self, **kwargs):
"""Override: if a website is set on the product or given, fallback to
the company of the website. Otherwise use the one from parent method."""
res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs)
website = self.website_id or kwargs.get('website')
return website and website.company_id or res
def _init_column(self, column_name):
# to avoid generating a single default website_sequence when installing the module,
# we need to set the default row by row for this column
@@ -418,7 +608,7 @@ class ProductTemplate(models.Model):
return self.set_sequence_bottom()
def _default_website_meta(self):
res = super(ProductTemplate, self)._default_website_meta()
res = super()._default_website_meta()
res['default_opengraph']['og:description'] = res['default_twitter']['twitter:description'] = self.description_sale
res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name
res['default_opengraph']['og:image'] = res['default_twitter']['twitter:image'] = self.env['website'].image_url(self, 'image_1024')
@@ -426,7 +616,7 @@ class ProductTemplate(models.Model):
return res
def _compute_website_url(self):
super(ProductTemplate, self)._compute_website_url()
super()._compute_website_url()
for product in self:
if product.id:
product.website_url = "/shop/%s" % slug(product)
@@ -441,7 +631,7 @@ class ProductTemplate(models.Model):
def _rating_domain(self):
""" Only take the published rating into account to compute avg and count """
domain = super(ProductTemplate, self)._rating_domain()
domain = super()._rating_domain()
return expression.AND([domain, [('is_internal', '=', False)]])
def _get_images(self):
@@ -550,7 +740,7 @@ class ProductTemplate(models.Model):
def _search_render_results_prices(self, mapping, combination_info):
monetary_options = {'display_currency': mapping['detail']['display_currency']}
if combination_info['prevent_zero_price_sale']:
if combination_info.get('prevent_zero_price_sale'):
website = self.env['website'].get_current_website()
price = website.prevent_zero_price_sale_text
else:
@@ -568,26 +758,23 @@ class ProductTemplate(models.Model):
return price, list_price if combination_info['has_discounted_price'] else None
@api.model
def get_google_analytics_data(self, combination):
product = self.env['product.product'].browse(combination['product_id'])
def _get_google_analytics_data(self, product, combination_info):
self.ensure_one()
return {
'item_id': product.barcode or product.id,
'item_name': combination['display_name'],
'item_category': product.categ_id.name or '-',
'currency': product.currency_id.name,
'price': combination['list_price'],
'item_name': combination_info['display_name'],
'item_category': self.categ_id.name,
'currency': combination_info['currency'].name,
'price': combination_info['list_price'],
}
def _get_contextual_pricelist(self):
""" Override to fallback on website current pricelist
"""
""" Override to fallback on website current pricelist """
pricelist = super()._get_contextual_pricelist()
if pricelist:
return pricelist
website = ir_http.get_request_website()
if website:
return website.get_current_pricelist()
if not pricelist:
website = ir_http.get_request_website()
if website:
return website.pricelist_id
return pricelist
def _website_show_quick_add(self):
@@ -0,0 +1,25 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import OrderedDict
from odoo import models
class ProductTemplateAttributeLine(models.Model):
_inherit = 'product.template.attribute.line'
def _prepare_single_value_for_display(self):
"""On the product page group together the attribute lines that concern
the same attribute and that have only one value each.
Indeed those are considered informative values, they do not generate
choice for the user, so they are displayed below the configurator.
The returned attributes are ordered as they appear in `self`, so based
on the order of the attribute lines.
"""
single_value_lines = self.filtered(lambda ptal: len(ptal.value_ids) == 1)
single_value_attributes = OrderedDict([(pa, self.env['product.template.attribute.line']) for pa in single_value_lines.attribute_id])
for ptal in single_value_lines:
single_value_attributes[ptal.attribute_id] |= ptal
return single_value_attributes
@@ -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
+24 -28
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):
@@ -35,6 +31,8 @@ class Website(models.Model):
required=True, default='tax_excluded',
)
fiscal_position_id = fields.Many2one(
'account.fiscal.position', compute='_compute_fiscal_position_id')
pricelist_id = fields.Many2one(
'product.pricelist', compute='_compute_pricelist_id', string="Default Pricelist if any")
currency_id = fields.Many2one(
@@ -125,11 +123,11 @@ class Website(models.Model):
def _compute_pricelist_id(self):
for website in self:
website.pricelist_id = website.get_current_pricelist()
website.pricelist_id = website._get_current_pricelist()
# NOTE VFE: moving this computation doesn't change much
# Because most of it must still be computed for the pricelist choice template (`pricelist_list`)
# Therefore, avoiding all pricelist computation is impossible in fact...
def _compute_fiscal_position_id(self):
for website in self:
website.fiscal_position_id = website._get_current_fiscal_position()
@api.depends('all_pricelist_ids', 'pricelist_id', 'company_id')
def _compute_currency_id(self):
@@ -252,7 +250,7 @@ class Website(models.Model):
def _get_cached_pricelist_id(self):
return request and request.session.get('website_sale_current_pl') or None
def get_current_pricelist(self):
def _get_current_pricelist(self):
"""
:returns: The current pricelist record
"""
@@ -387,7 +385,9 @@ class Website(models.Model):
# Reset the session pricelist according to logged partner pl
request.session.pop('website_sale_current_pl', None)
pricelist_id = self._get_current_pricelist_id(partner_sudo)
# Force recomputation of the website pricelist after reset
self.invalidate_recordset(['pricelist_id'])
pricelist_id = self.pricelist_id.id
request.session['website_sale_current_pl'] = pricelist_id
# change the partner, and trigger the computes (fpos)
@@ -406,7 +406,7 @@ class Website(models.Model):
update_pricelist = True
elif update_pricelist:
# Only compute pricelist if needed
pricelist_id = self._get_current_pricelist_id(partner_sudo)
pricelist_id = self.pricelist_id.id
# update the pricelist
if update_pricelist:
@@ -434,18 +434,15 @@ class Website(models.Model):
if not salesperson_user_sudo:
salesperson_user_sudo = self.salesperson_id or partner_sudo.parent_id.user_id or partner_sudo.user_id
pricelist_id = self._get_current_pricelist_id(partner_sudo)
fiscal_position_id = self._get_current_fiscal_position_id(partner_sudo)
values = {
'company_id': self.company_id.id,
'fiscal_position_id': fiscal_position_id,
'fiscal_position_id': self.fiscal_position_id.id,
'partner_id': partner_sudo.id,
'partner_invoice_id': partner_sudo.id,
'partner_shipping_id': addr['delivery'],
'pricelist_id': pricelist_id,
'pricelist_id': self.pricelist_id.id,
'payment_term_id': self.sale_get_payment_term(partner_sudo),
'team_id': self.salesteam_id.id or partner_sudo.parent_id.team_id.id or partner_sudo.team_id.id,
@@ -467,26 +464,24 @@ class Website(models.Model):
self.env['account.payment.term'].sudo().search([('company_id', '=', self.company_id.id)], limit=1)
).id
def _get_current_pricelist_id(self, partner_sudo):
return self.get_current_pricelist().id \
or partner_sudo.property_product_pricelist.id
@api.model
def _get_current_fiscal_position_id(self, partner_sudo):
def _get_current_fiscal_position(self):
AccountFiscalPosition = self.env['account.fiscal.position'].sudo()
fpos = AccountFiscalPosition
partner_sudo = self.env.user.partner_id
# If the current user is the website public user, the fiscal position
# is computed according to geolocation.
if request and request.website.partner_id.id == partner_sudo.id:
if request.geoip.country_code:
country_id = self.env['res.country'].search([('code', '=', request.geoip.country_code)], limit=1).id
fpos = AccountFiscalPosition._get_fpos_by_region(country_id)
if request and request.geoip.country_code and self.partner_id.id == partner_sudo.id:
country = self.env['res.country'].search(
[('code', '=', request.geoip.country_code)],
limit=1,
).id
fpos = AccountFiscalPosition._get_fpos_by_region(country.id)
if not fpos:
fpos = AccountFiscalPosition._get_fiscal_position(partner_sudo)
return fpos.id
return fpos
def sale_reset(self):
request.session.pop('sale_order_id', None)
@@ -568,6 +563,7 @@ class Website(models.Model):
return self.is_view_active('website_sale.address_b2b')
class WebsiteSaleExtraField(models.Model):
_name = 'website.sale.extra.field'
_description = 'E-Commerce Extra Info Shown on product page'
@@ -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,
},
+1 -1
View File
@@ -57,7 +57,7 @@ class TestUi(HttpCaseWithUserDemo, HttpCaseWithUserPortal):
# Update the pricelist currency regarding env.company_id currency_id in case company has changed currency with COA installation.
website = self.env['website'].get_current_website()
pricelist = website.get_current_pricelist()
pricelist = website.pricelist_id
pricelist.write({'currency_id': self.env.company.currency_id.id})
def test_01_admin_shop_customize_tour(self):
@@ -195,7 +195,7 @@ class TestWebsitePriceList(TransactionCase):
'taxes_id': False,
})
current_website = self.env['website'].get_current_website()
website_pricelist = current_website.get_current_pricelist()
website_pricelist = current_website.pricelist_id
website_pricelist.write({
'discount_policy': 'with_discount',
'item_ids': [(5, 0, 0), (0, 0, {
@@ -246,7 +246,7 @@ class TestWebsitePriceList(TransactionCase):
'taxes_id': False,
})
current_website = self.env['website'].get_current_website()
website_pricelist = current_website.get_current_pricelist()
website_pricelist = current_website.pricelist_id
website_pricelist.write({
'discount_policy': 'without_discount',
'item_ids': [(5, 0, 0), (0, 0, {
@@ -293,7 +293,7 @@ class TestWebsitePriceList(TransactionCase):
'taxes_id': tax,
})
prices = product._get_sales_prices(self.list_christmas)
prices = product._get_sales_prices(self.list_christmas, self.env['account.fiscal.position'])
self.assertFalse('base_price' in prices[product.id])
def test_pricelist_item_based_on_cost_for_templates(self):
@@ -317,7 +317,8 @@ class TestWebsitePriceList(TransactionCase):
'name': 'Product Template', 'list_price': 10.0, 'standard_price': 5.0
})
self.assertEqual(product_template.standard_price, 5)
price = product_template._get_sales_prices(pricelist)[product_template.id]['price_reduce']
price = product_template._get_sales_prices(
pricelist, self.env['account.fiscal.position'])[product_template.id]['price_reduce']
msg = "Template has no variants, the price should be computed based on the template's cost."
self.assertEqual(price, 4.5, msg)
@@ -328,12 +329,14 @@ class TestWebsitePriceList(TransactionCase):
self.assertEqual(product_template.standard_price, 0, msg)
self.assertEqual(product_template.product_variant_ids[0].standard_price, 0)
price = product_template._get_sales_prices(pricelist)[product_template.id]['price_reduce']
price = product_template._get_sales_prices(
pricelist, self.env['account.fiscal.position'])[product_template.id]['price_reduce']
msg = "Template has variants, the price should be computed based on the 1st variant's cost."
self.assertEqual(price, 0, msg)
product_template.product_variant_ids[0].standard_price = 20
price = product_template._get_sales_prices(pricelist)[product_template.id]['price_reduce']
price = product_template._get_sales_prices(
pricelist, self.env['account.fiscal.position'])[product_template.id]['price_reduce']
self.assertEqual(price, 18, msg)
def simulate_frontend_context(self, website_id=1):
@@ -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,8 @@ class TestWebsiteSaleProductAttributeValueConfig(AccountTestInvoicingCommon, Tes
# Now with fiscal position, taxes should be mapped
self.env.user.partner_id.country_id = us_country
combination_info = product._get_combination_info(pricelist=pricelist)
website.invalidate_recordset(['fiscal_position_id'])
combination_info = product._get_combination_info()
self.assertEqual(combination_info['price'], 500, "500% + 0% tax (mapped from fp 15% -> 0%)")
self.assertEqual(combination_info['list_price'], 500, "500% + 0% tax (mapped from fp 15% -> 0%)")
self.assertEqual(combination_info['price_extra'], 200, "200% + 0% tax (mapped from fp 15% -> 0%)")
@@ -148,14 +152,16 @@ class TestWebsiteSaleProductAttributeValueConfig(AccountTestInvoicingCommon, Tes
# Reset / Safety check
self.env.user.partner_id.country_id = None
combination_info = product._get_combination_info(pricelist=pricelist)
website.invalidate_recordset(['fiscal_position_id'])
combination_info = product._get_combination_info()
self.assertEqual(combination_info['price'], 500, "434.78$ + 15% tax")
self.assertEqual(combination_info['list_price'], 500, "434.78$ + 15% tax (2)")
self.assertEqual(combination_info['price_extra'], 200, "173.91$ + 15% tax")
# Now with fiscal position, taxes should be mapped
self.env.user.partner_id.country_id = us_country.id
combination_info = product._get_combination_info(pricelist=pricelist)
website.invalidate_recordset(['fiscal_position_id'])
combination_info = product._get_combination_info()
self.assertEqual(round(combination_info['price'], 2), 434.78, "434.78$ + 0% tax (mapped from fp 15% -> 0%)")
self.assertEqual(round(combination_info['list_price'], 2), 434.78, "434.78$ + 0% tax (mapped from fp 15% -> 0%)")
self.assertEqual(combination_info['price_extra'], 173.91, "173.91$ + 0% tax (mapped from fp 15% -> 0%)")
+43 -33
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) -->
@@ -946,18 +946,20 @@
Change accordingly to the derired visual result (eg. `primary`, `dark`...)-->
<t t-set="navClass" t-valuef="light"/>
<t t-cache="pricelist,product">
<t t-cache="pricelist,product,fiscal_position">
<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>
@@ -1558,9 +1563,6 @@
</div>
</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)"/>
<span t-if="website.show_line_subtotals_tax_selection == 'tax_excluded'"
t-field="line.price_reduce_taxexcl" style="white-space: nowrap;"
t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}"/>
@@ -1568,7 +1570,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="line.price_unit"
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 +1715,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 +1730,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>
@@ -1,13 +1,14 @@
# -*- coding: utf-8 -*-
from odoo import http
from odoo.http import request
from odoo.addons.website_sale.controllers.main import WebsiteSale
# 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.main import WebsiteSale
class WebsiteSaleProductComparison(WebsiteSale):
@http.route('/shop/compare', type='http', auth="public", website=True, sitemap=False)
@route('/shop/compare', type='http', auth='public', website=True, sitemap=False)
def product_compare(self, **post):
values = {}
product_ids = [int(i) for i in post.get('products', '').split(',') if i.isdigit()]
@@ -18,18 +19,17 @@ class WebsiteSaleProductComparison(WebsiteSale):
values['products'] = products.with_context(display_default_code=False)
return request.render("website_sale_comparison.product_compare", values)
@http.route(['/shop/get_product_data'], type='json', auth="public", website=True)
@route('/shop/get_product_data', type='json', auth='public', website=True)
def get_product_data(self, product_ids, cookies=None):
ret = {}
website = request.env['website'].get_current_website()
pricelist = website.pricelist_id
products = request.env['product.product'].search([('id', 'in', product_ids)])
if cookies is not None:
ret['cookies'] = json.dumps(request.env['product.product'].search([('id', 'in', list(set(product_ids + cookies)))]).ids)
products = products.with_context(pricelist=pricelist.id, display_default_code=False)
products = products.with_context(display_default_code=False)
for product in products:
ret[product.id] = {
'render': request.env['ir.ui.view']._render_template(
@@ -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>