[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:
@@ -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:
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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%)")
|
||||
|
||||
@@ -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'] > 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) > 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" />
|
||||
|
||||
@@ -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'] > 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'] > 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'] > 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'] > 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;
|
||||
}
|
||||
});
|
||||
+31
-19
@@ -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>
|
||||
|
||||
|
||||
-16
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user