mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-27 12:52:07 +02:00
1123 lines
48 KiB
Python
1123 lines
48 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import logging
|
|
from collections import defaultdict
|
|
|
|
from werkzeug import urls
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.fields import Domain
|
|
from odoo.http import request
|
|
from odoo.tools import float_is_zero, is_html_empty
|
|
from odoo.tools.sql import SQL, column_exists, create_column
|
|
from odoo.tools.translate import html_translate
|
|
|
|
from odoo.addons.website.models import ir_http
|
|
from odoo.addons.website.tools import text_from_html
|
|
from odoo.addons.website_sale.const import SHOP_PATH
|
|
|
|
# A delimiter that users aren't likely to search for in product codes.
|
|
RARE_DELIMITER = '\u241E'
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_translated_field_gist_index(registry, column_name):
|
|
if not registry.has_trigram:
|
|
return ""
|
|
if registry.has_unaccent:
|
|
return f"USING GIST(unaccent((JSONB_PATH_QUERY_ARRAY({column_name}, '$.*'::jsonpath))::text) gist_trgm_ops)"
|
|
return f"USING GIST((JSONB_PATH_QUERY_ARRAY({column_name}, '$.*'::jsonpath)::text) gist_trgm_ops)"
|
|
|
|
|
|
class ProductTemplate(models.Model):
|
|
_name = 'product.template'
|
|
_inherit = [
|
|
'rating.mixin',
|
|
'product.template',
|
|
'website.seo.metadata',
|
|
'website.published.multi.mixin',
|
|
'website.searchable.mixin',
|
|
]
|
|
_mail_post_access = 'read'
|
|
_check_company_auto = True
|
|
|
|
#=== DEFAULT METHODS ===#
|
|
|
|
@api.model
|
|
def _default_website_sequence(self):
|
|
""" We want new product to be the last (highest seq).
|
|
Every product should ideally have an unique sequence.
|
|
Default sequence (10000) should only be used for DB first product.
|
|
As we don't resequence the whole tree (as `sequence` does), this field
|
|
might have negative value.
|
|
"""
|
|
self.env.cr.execute('SELECT MAX(website_sequence) FROM %s' % self._table)
|
|
max_sequence = self.env.cr.fetchone()[0]
|
|
if max_sequence is None:
|
|
return 10000
|
|
return max_sequence + 5
|
|
|
|
#=== FIELDS ===#
|
|
|
|
website_description = fields.Html(
|
|
string="Description for the website",
|
|
translate=html_translate,
|
|
sanitize_overridable=True,
|
|
sanitize_attributes=False,
|
|
sanitize_form=False,
|
|
index='trigram',
|
|
)
|
|
description_ecommerce = fields.Html(
|
|
string="eCommerce Description",
|
|
translate=html_translate,
|
|
sanitize_overridable=True,
|
|
sanitize_attributes=False,
|
|
sanitize_form=False,
|
|
)
|
|
|
|
alternative_product_ids = fields.Many2many(
|
|
string="Alternative Products",
|
|
comodel_name='product.template',
|
|
relation='product_alternative_rel',
|
|
column1='src_id', column2='dest_id',
|
|
check_company=True,
|
|
help="Suggest alternatives to your customer (upsell strategy)."
|
|
" Those products show up on the product page.",
|
|
)
|
|
accessory_product_ids = fields.Many2many(
|
|
string="Accessory Products",
|
|
comodel_name='product.product',
|
|
relation='product_accessory_rel',
|
|
column1='src_id', column2='dest_id',
|
|
check_company=True,
|
|
help="Accessories show up when the customer reviews the cart before payment"
|
|
" (cross-sell strategy).",
|
|
)
|
|
|
|
website_size_x = fields.Integer(string="Size X", default=1)
|
|
website_size_y = fields.Integer(string="Size Y", default=1)
|
|
website_ribbon_id = fields.Many2one(string="Ribbon", comodel_name='product.ribbon')
|
|
website_sequence = fields.Integer(
|
|
string="Website Sequence",
|
|
help="Determine the display order in the Website E-commerce",
|
|
default=_default_website_sequence,
|
|
copy=False,
|
|
index=True,
|
|
)
|
|
public_categ_ids = fields.Many2many(
|
|
string="Website Product Category",
|
|
help="The product will be available in each mentioned eCommerce category. Go to Shop > Edit"
|
|
" Click on the page and enable 'Categories' to view all eCommerce categories.",
|
|
comodel_name='product.public.category',
|
|
relation='product_public_category_product_template_rel',
|
|
)
|
|
|
|
publish_date = fields.Datetime(
|
|
string="Publish Date",
|
|
compute='_compute_publish_date',
|
|
store=True,
|
|
required=True,
|
|
default=fields.Datetime.now,
|
|
)
|
|
|
|
product_template_image_ids = fields.One2many(
|
|
string="Extra Product Media",
|
|
comodel_name='product.image',
|
|
inverse_name='product_tmpl_id',
|
|
copy=True,
|
|
)
|
|
|
|
base_unit_count = fields.Float(
|
|
string="Base Unit Count",
|
|
help="Display base unit price on your eCommerce pages. Set to 0 to hide it for this product.",
|
|
compute='_compute_base_unit_count',
|
|
inverse='_set_base_unit_count',
|
|
store=True,
|
|
required=True,
|
|
default=0,
|
|
)
|
|
base_unit_id = fields.Many2one(
|
|
string="Custom Unit of Measure",
|
|
help="Define a custom unit to display in the price per unit of measure field.",
|
|
comodel_name='website.base.unit',
|
|
compute='_compute_base_unit_id',
|
|
inverse='_set_base_unit_id',
|
|
store=True,
|
|
)
|
|
base_unit_price = fields.Monetary(string="Price Per Unit", compute="_compute_base_unit_price")
|
|
base_unit_name = fields.Char(
|
|
compute='_compute_base_unit_name',
|
|
help="Displays the custom unit for the products if defined or the selected unit of measure"
|
|
" otherwise.",
|
|
)
|
|
|
|
compare_list_price = fields.Monetary(
|
|
string="Compare to Price",
|
|
help="Add a strikethrough price to your /shop and product pages for comparison purposes."
|
|
"It will not be displayed if pricelists apply.",
|
|
)
|
|
variants_default_code = fields.Char(
|
|
compute='_compute_variants_default_code',
|
|
store=True,
|
|
index='trigram',
|
|
help="Technical field to enhance performance when looking up default code of product"
|
|
"variants (LIKE/ILIKE)",
|
|
)
|
|
description = fields.Html(index='trigram')
|
|
description_sale = fields.Text(index='trigram')
|
|
|
|
# === INDEXES === #
|
|
|
|
# We need gist indexes for similarity check in ecommerce fuzzy search.
|
|
_name_gist_idx = models.Index(lambda registry: get_translated_field_gist_index(registry, "name"))
|
|
_description_gist_idx = models.Index(lambda registry: get_translated_field_gist_index(registry, "description"))
|
|
_description_sale_gist_idx = models.Index(lambda registry: get_translated_field_gist_index(registry, "description_sale"))
|
|
_default_code_gist_idx = models.Index(
|
|
lambda registry: 'USING GIST(unaccent(default_code) gist_trgm_ops)'
|
|
if registry.has_trigram and registry.has_unaccent
|
|
else ('USING GIST(default_code gist_trgm_ops)' if registry.has_trigram else '')
|
|
)
|
|
|
|
def _auto_init(self):
|
|
"""Override _auto_init to prevent MemoryError on ecommerce installation in dbs with lots of products"""
|
|
if not column_exists(self.env.cr, 'product_template', 'variants_default_code'):
|
|
create_column(self.env.cr, 'product_template', 'variants_default_code', 'varchar')
|
|
self.env.cr.execute(SQL(
|
|
"""
|
|
UPDATE product_template
|
|
SET variants_default_code = variants.default_codes
|
|
FROM (
|
|
SELECT pt.id AS template_id,
|
|
STRING_AGG(pv.default_code, %s) AS default_codes
|
|
FROM product_template pt
|
|
JOIN product_product pv ON pv.product_tmpl_id = pt.id
|
|
WHERE pv.default_code IS NOT NULL
|
|
GROUP BY pt.id
|
|
) AS variants
|
|
WHERE product_template.id = variants.template_id
|
|
""", RARE_DELIMITER))
|
|
return super()._auto_init()
|
|
|
|
#=== COMPUTE METHODS ===#
|
|
|
|
@api.depends('is_published')
|
|
def _compute_publish_date(self):
|
|
"""Set `publish_date` to the moment of (re-)publishing."""
|
|
self.filtered('is_published').publish_date = fields.Datetime.now()
|
|
|
|
@api.depends('product_variant_ids', 'product_variant_ids.base_unit_count')
|
|
def _compute_base_unit_count(self):
|
|
self.base_unit_count = 0
|
|
for template in self.filtered(lambda template: len(template.product_variant_ids) == 1):
|
|
template.base_unit_count = template.product_variant_ids.base_unit_count
|
|
|
|
def _set_base_unit_count(self):
|
|
for template in self:
|
|
if len(template.product_variant_ids) == 1:
|
|
template.product_variant_ids.base_unit_count = template.base_unit_count
|
|
|
|
@api.depends('product_variant_ids', 'product_variant_ids.base_unit_count')
|
|
def _compute_base_unit_id(self):
|
|
self.base_unit_id = self.env['website.base.unit']
|
|
for template in self.filtered(lambda template: len(template.product_variant_ids) == 1):
|
|
template.base_unit_id = template.product_variant_ids.base_unit_id
|
|
|
|
def _set_base_unit_id(self):
|
|
for template in self:
|
|
if len(template.product_variant_ids) == 1:
|
|
template.product_variant_ids.base_unit_id = template.base_unit_id
|
|
|
|
def _get_base_unit_price(self, price):
|
|
self.ensure_one()
|
|
return self.base_unit_count and price / self.base_unit_count
|
|
|
|
@api.depends('list_price', 'base_unit_count')
|
|
def _compute_base_unit_price(self):
|
|
for template in self:
|
|
template.base_unit_price = template._get_base_unit_price(template.list_price)
|
|
|
|
@api.depends('uom_name', 'base_unit_id.name')
|
|
def _compute_base_unit_name(self):
|
|
for template in self:
|
|
template.base_unit_name = template.base_unit_id.name or template.uom_name
|
|
|
|
def _compute_website_url(self):
|
|
super()._compute_website_url()
|
|
for product in self:
|
|
if product.id:
|
|
product.website_url = "/shop/%s" % self.env['ir.http']._slug(product)
|
|
|
|
@api.depends('product_variant_ids.default_code')
|
|
def _compute_variants_default_code(self):
|
|
for template in self:
|
|
template.variants_default_code = RARE_DELIMITER.join(
|
|
template.product_variant_ids.filtered('default_code').mapped('default_code')
|
|
)
|
|
|
|
#=== CRUD METHODS ===#
|
|
|
|
def write(self, vals):
|
|
# Clear empty ecommerce description content to avoid side-effects on product pages
|
|
# when there is no content to display anyway.
|
|
if (
|
|
(description_ecommerce := vals.get('description_ecommerce'))
|
|
and is_html_empty(description_ecommerce)
|
|
and not ('media_iframe_video' in description_ecommerce or 'data-embedded' in description_ecommerce) # don't remove "empty" video div
|
|
):
|
|
vals['description_ecommerce'] = ''
|
|
return super().write(vals)
|
|
|
|
#=== BUSINESS METHODS ===#
|
|
|
|
def _prepare_variant_values(self, combination):
|
|
variant_dict = super()._prepare_variant_values(combination)
|
|
variant_dict['base_unit_count'] = self.base_unit_count
|
|
return variant_dict
|
|
|
|
def _get_website_accessory_product(self):
|
|
domain = Domain(self.env['website'].sale_product_domain())
|
|
if not self.env.user._is_internal():
|
|
domain &= Domain('is_published', '=', True)
|
|
return self.accessory_product_ids.filtered_domain(domain)
|
|
|
|
def _get_website_alternative_product(self):
|
|
domain = self.env['website'].sale_product_domain()
|
|
return self.alternative_product_ids.filtered_domain(domain)
|
|
|
|
def _has_no_variant_attributes(self):
|
|
"""Return whether this `product.template` has at least one no_variant
|
|
attribute.
|
|
|
|
:return: True if at least one no_variant attribute, False otherwise
|
|
:rtype: bool
|
|
"""
|
|
self.ensure_one()
|
|
return any(a.create_variant == 'no_variant' for a in self.valid_product_template_attribute_line_ids.attribute_id)
|
|
|
|
def _has_is_custom_values(self):
|
|
self.ensure_one()
|
|
"""Return whether this `product.template` has at least one is_custom
|
|
attribute value.
|
|
|
|
:return: True if at least one is_custom attribute value, False otherwise
|
|
:rtype: bool
|
|
"""
|
|
return any(v.is_custom for v in self.valid_product_template_attribute_line_ids.product_template_value_ids._only_active())
|
|
|
|
def _get_possible_variants_sorted(self, parent_combination=None):
|
|
"""Return the sorted recordset of variants that are possible.
|
|
|
|
The order is based on the order of the attributes and their values.
|
|
|
|
See `_get_possible_variants` for the limitations of this method with
|
|
dynamic or no_variant attributes, and also for a warning about
|
|
performances.
|
|
|
|
:param parent_combination: combination from which `self` is an
|
|
optional or accessory product
|
|
:type parent_combination: recordset `product.template.attribute.value`
|
|
|
|
:return: the sorted variants that are possible
|
|
:rtype: recordset of `product.product`
|
|
"""
|
|
self.ensure_one()
|
|
|
|
def _sort_key_attribute_value(value):
|
|
# if you change this order, keep it in sync with _order from `product.attribute`
|
|
return (value.attribute_id.sequence, value.attribute_id.id)
|
|
|
|
def _sort_key_variant(variant):
|
|
"""
|
|
We assume all variants will have the same attributes, with only one value for each.
|
|
- first level sort: same as "product.attribute"._order
|
|
- second level sort: same as "product.attribute.value"._order
|
|
"""
|
|
keys = []
|
|
for attribute in variant.product_template_attribute_value_ids.sorted(_sort_key_attribute_value):
|
|
# if you change this order, keep it in sync with _order from `product.attribute.value`
|
|
keys.append(attribute.product_attribute_value_id.sequence)
|
|
keys.append(attribute.id)
|
|
return keys
|
|
|
|
return self._get_possible_variants(parent_combination).sorted(_sort_key_variant)
|
|
|
|
def _get_previewed_attribute_values(self, category=None, product_query_params=None):
|
|
"""Compute previewed product attribute values for each product in the recordset.
|
|
|
|
:return: the previewed attribute values per product
|
|
:rtype: dict
|
|
"""
|
|
res = defaultdict(dict)
|
|
show_count = 20
|
|
for template in self:
|
|
previewed_ptal = next((
|
|
p for p in template.attribute_line_ids
|
|
if p.attribute_id.preview_variants != 'hidden'
|
|
), None)
|
|
if previewed_ptal:
|
|
previewed_ptavs = [
|
|
ptav
|
|
for ptav in previewed_ptal.product_template_value_ids
|
|
if ptav.ptav_active and ptav.ptav_product_variant_ids
|
|
]
|
|
|
|
if len(previewed_ptavs) > 1:
|
|
previewed_ptavs_data = []
|
|
for ptav in previewed_ptavs[:show_count]:
|
|
matching_variant = min(ptav.ptav_product_variant_ids, key=lambda p: p.id)
|
|
variant_query_params = {
|
|
**(product_query_params or {}),
|
|
'attribute_values': str(ptav.product_attribute_value_id.id)
|
|
}
|
|
previewed_ptavs_data.append({
|
|
'ptav': ptav,
|
|
'variant_image_url': self.env['website'].image_url(matching_variant, 'image_512'),
|
|
'variant_url': template._get_product_url(category, variant_query_params),
|
|
})
|
|
|
|
res[template.id] = {
|
|
'ptavs_data': previewed_ptavs_data,
|
|
'hidden_ptavs_count': max(0, len(previewed_ptavs) - show_count)
|
|
}
|
|
return res
|
|
|
|
def _get_sales_prices(self, website):
|
|
if not self:
|
|
return {}
|
|
|
|
pricelist = request.pricelist
|
|
currency = website.currency_id
|
|
fiscal_position_sudo = request.fiscal_position
|
|
date = fields.Date.context_today(self)
|
|
|
|
pricelist_prices = pricelist._compute_price_rule(self, 1.0)
|
|
comparison_prices_enabled = self.env['res.groups']._is_feature_enabled(
|
|
'website_sale.group_product_price_comparison'
|
|
)
|
|
|
|
res = {}
|
|
for template in self:
|
|
pricelist_price, pricelist_rule_id = pricelist_prices[template.id]
|
|
|
|
product_taxes = template.sudo().taxes_id._filter_taxes_by_company(self.env.company)
|
|
taxes = fiscal_position_sudo.map_tax(product_taxes)
|
|
|
|
base_price = None
|
|
template_price_vals = {
|
|
'price_reduce': self._apply_taxes_to_price(
|
|
pricelist_price, currency, product_taxes, taxes, template, website=website,
|
|
),
|
|
}
|
|
pricelist_item = template.env['product.pricelist.item'].browse(pricelist_rule_id)
|
|
if pricelist_item._show_discount_on_shop():
|
|
pricelist_base_price = pricelist_item._compute_price_before_discount(
|
|
product=template,
|
|
quantity=1.0,
|
|
date=date,
|
|
uom=template.uom_id,
|
|
currency=currency,
|
|
)
|
|
if currency.compare_amounts(pricelist_base_price, pricelist_price) == 1:
|
|
base_price = pricelist_base_price
|
|
template_price_vals['base_price'] = self._apply_taxes_to_price(
|
|
base_price, currency, product_taxes, taxes, template, website=website,
|
|
)
|
|
|
|
if not base_price and comparison_prices_enabled and template.compare_list_price:
|
|
template_price_vals['base_price'] = template.currency_id._convert(
|
|
template.compare_list_price,
|
|
currency,
|
|
self.env.company,
|
|
date,
|
|
round=False,
|
|
)
|
|
|
|
res[template.id] = template_price_vals
|
|
|
|
return res
|
|
|
|
def _can_be_added_to_cart(self):
|
|
"""
|
|
Pre-check to `_is_add_to_cart_possible` to know if product can be sold.
|
|
"""
|
|
self.ensure_one()
|
|
return bool(self.filtered_domain(self.env['website']._product_domain()))
|
|
|
|
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, uom_id=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 int|None uom_id: the uom for which to get the info, as an `uom.uom` id.
|
|
|
|
: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()
|
|
|
|
combination = combination or self.env['product.template.attribute.value']
|
|
website = request.website.with_context(self.env.context)
|
|
uom = self.env['uom.uom'].browse(uom_id) or self.uom_id
|
|
|
|
if not product_id and not combination and not only_template:
|
|
combination = self._get_first_possible_combination()
|
|
|
|
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_or_template = product or self
|
|
combination = combination or product.product_template_attribute_value_ids
|
|
|
|
display_name = product_or_template.with_context(display_default_code=False).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 = {
|
|
'combination': combination,
|
|
'product_id': product.id,
|
|
'product_template_id': self.id,
|
|
'display_name': display_name,
|
|
'is_combination_possible': self._is_combination_possible(combination=combination),
|
|
|
|
**self._get_additionnal_combination_info(
|
|
product_or_template=product_or_template,
|
|
quantity=add_qty or 1.0,
|
|
uom=uom,
|
|
date=fields.Date.context_today(self),
|
|
website=website,
|
|
)
|
|
}
|
|
|
|
if website.google_analytics_key:
|
|
combination_info['product_tracking_info'] = self._get_google_analytics_data(
|
|
product,
|
|
combination_info,
|
|
)
|
|
|
|
if (
|
|
product_or_template.type == 'combo'
|
|
and website.show_line_subtotals_tax_selection == 'tax_included'
|
|
and not all(
|
|
tax.price_include
|
|
for tax
|
|
in product_or_template.sudo().combo_ids.combo_item_ids.product_id.taxes_id
|
|
)
|
|
):
|
|
combination_info['tax_disclaimer'] = _(
|
|
"Final price may vary based on selection. Tax will be calculated at checkout."
|
|
)
|
|
|
|
return combination_info
|
|
|
|
def _get_additionnal_combination_info(self, product_or_template, quantity, uom, date, website):
|
|
"""Compute 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: requested quantity
|
|
:param uom: `uom.uom` record
|
|
: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 = request.pricelist.with_context(self.env.context)
|
|
currency = website.currency_id.with_context(self.env.context)
|
|
|
|
# Pricelist price doesn't have to be converted
|
|
pricelist_price, pricelist_rule_id = pricelist._get_product_price_rule(
|
|
product=product_or_template,
|
|
quantity=quantity,
|
|
uom=uom,
|
|
currency=currency,
|
|
)
|
|
|
|
price_before_discount = pricelist_price
|
|
pricelist_item = self.env['product.pricelist.item'].browse(pricelist_rule_id)
|
|
if pricelist_item._show_discount_on_shop():
|
|
price_before_discount = pricelist_item._compute_price_before_discount(
|
|
product=product_or_template,
|
|
quantity=quantity or 1.0,
|
|
date=date,
|
|
uom=uom,
|
|
currency=currency,
|
|
)
|
|
|
|
has_discounted_price = currency.compare_amounts(price_before_discount, pricelist_price) == 1
|
|
combination_info = {
|
|
'list_price': max(pricelist_price, price_before_discount),
|
|
'price': pricelist_price,
|
|
'has_discounted_price': has_discounted_price,
|
|
'discount_start_date': pricelist_item.date_start,
|
|
'discount_end_date': pricelist_item.date_end,
|
|
}
|
|
|
|
if (
|
|
not has_discounted_price
|
|
and product_or_template.compare_list_price
|
|
and self.env['res.groups']._is_feature_enabled(
|
|
'website_sale.group_product_price_comparison'
|
|
)
|
|
):
|
|
# TODO VCR comparison price only depends on the product template, but is shown/hidden
|
|
# depending on product price, should be removed from combination info in the future
|
|
combination_info['compare_list_price'] = product_or_template.currency_id._convert(
|
|
from_amount=product_or_template.compare_list_price,
|
|
to_currency=currency,
|
|
company=self.env.company,
|
|
date=date,
|
|
round=False,
|
|
)
|
|
|
|
# Apply taxes
|
|
product_taxes = product_or_template.sudo().taxes_id._filter_taxes_by_company(self.env.company)
|
|
taxes = self.env['account.tax']
|
|
if product_taxes:
|
|
taxes = request.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'):
|
|
combination_info[price_key] = self._apply_taxes_to_price(
|
|
combination_info[price_key],
|
|
currency,
|
|
product_taxes,
|
|
taxes,
|
|
product_or_template,
|
|
website=website,
|
|
)
|
|
|
|
combination_info.update({
|
|
'prevent_zero_price_sale': website.prevent_zero_price_sale and float_is_zero(
|
|
combination_info['price'],
|
|
precision_rounding=currency.rounding,
|
|
),
|
|
|
|
# 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 self.env['res.groups']._is_feature_enabled('website_sale.group_show_uom_price'):
|
|
price_per_product_uom = uom._compute_price(
|
|
price=combination_info['price'], to_unit=self.uom_id
|
|
)
|
|
combination_info.update({
|
|
'base_unit_name': product_or_template.base_unit_name,
|
|
'base_unit_price': product_or_template._get_base_unit_price(price_per_product_uom),
|
|
})
|
|
|
|
if combination_info['prevent_zero_price_sale']:
|
|
# If price is zero and prevent_zero_price_sale is enabled we don't want to send any
|
|
# price information regarding the product
|
|
combination_info['compare_list_price'] = 0
|
|
|
|
return combination_info
|
|
|
|
@api.model
|
|
def _apply_taxes_to_price(
|
|
self, price, currency, product_taxes, taxes, product_or_template,
|
|
website=None,
|
|
):
|
|
website = website or self.env['website'].get_current_website()
|
|
price = self.env['product.product']._get_tax_included_unit_price_from_price(
|
|
price,
|
|
product_taxes,
|
|
product_taxes_after_fp=taxes,
|
|
)
|
|
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_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.
|
|
If the product template has an image it is the product template,
|
|
otherwise if the product has variants it is the first variant
|
|
|
|
:return: this product template or the first product variant
|
|
:rtype: recordset of 'product.template' or recordset of 'product.product'
|
|
"""
|
|
self.ensure_one()
|
|
if self.image_128:
|
|
return self
|
|
variant = self.env['product.product'].browse(self._get_first_possible_variant_id())
|
|
# if the variant has no image anyway, spare some queries by using template
|
|
return variant if variant.image_variant_128 else self
|
|
|
|
def _get_suitable_image_size(self, columns, x_size, y_size):
|
|
if x_size == 1 and y_size == 1 and columns >= 3:
|
|
return 'image_512'
|
|
return 'image_1024'
|
|
|
|
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
|
|
if column_name == "website_sequence":
|
|
_logger.debug("Table '%s': setting default value of new column %s to unique values for each row", self._table, column_name)
|
|
self.env.cr.execute("SELECT id FROM %s WHERE website_sequence IS NULL" % self._table)
|
|
prod_tmpl_ids = self.env.cr.dictfetchall()
|
|
max_seq = self._default_website_sequence()
|
|
query = f"""
|
|
UPDATE {self._table}
|
|
SET website_sequence = p.web_seq
|
|
FROM (VALUES %s) AS p(p_id, web_seq)
|
|
WHERE id = p.p_id
|
|
"""
|
|
values_args = [(prod_tmpl['id'], max_seq + i * 5) for i, prod_tmpl in enumerate(prod_tmpl_ids)]
|
|
self.env.cr.execute_values(query, values_args)
|
|
else:
|
|
super()._init_column(column_name)
|
|
|
|
def set_sequence_top(self):
|
|
min_sequence = self.sudo().search([], order='website_sequence ASC', limit=1)
|
|
self.website_sequence = min_sequence.website_sequence - 5
|
|
|
|
def set_sequence_bottom(self):
|
|
max_sequence = self.sudo().search([], order='website_sequence DESC', limit=1)
|
|
self.website_sequence = max_sequence.website_sequence + 5
|
|
|
|
def set_sequence_up(self):
|
|
previous_product_tmpl = self.sudo().search([
|
|
('website_sequence', '<', self.website_sequence),
|
|
('website_published', '=', self.website_published),
|
|
], order='website_sequence DESC', limit=1)
|
|
if previous_product_tmpl:
|
|
previous_product_tmpl.website_sequence, self.website_sequence = self.website_sequence, previous_product_tmpl.website_sequence
|
|
else:
|
|
self.set_sequence_top()
|
|
|
|
def set_sequence_down(self):
|
|
next_prodcut_tmpl = self.search([
|
|
('website_sequence', '>', self.website_sequence),
|
|
('website_published', '=', self.website_published),
|
|
], order='website_sequence ASC', limit=1)
|
|
if next_prodcut_tmpl:
|
|
next_prodcut_tmpl.website_sequence, self.website_sequence = self.website_sequence, next_prodcut_tmpl.website_sequence
|
|
else:
|
|
return self.set_sequence_bottom()
|
|
|
|
def _default_website_meta(self):
|
|
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')
|
|
res['default_meta_description'] = self.description_sale
|
|
return res
|
|
|
|
@api.model
|
|
def _get_alternative_product_filter(self):
|
|
return self.env.ref('website_sale.dynamic_filter_cross_selling_alternative_products').id
|
|
|
|
@api.model
|
|
def _get_product_types_allow_zero_price(self):
|
|
"""
|
|
Returns a list of service_tracking (`product.template.service_tracking`) that can ignore the
|
|
`prevent_zero_price_sale` rule when buying products on a website.
|
|
"""
|
|
return []
|
|
|
|
# ---------------------------------------------------------
|
|
# Rating Mixin API
|
|
# ---------------------------------------------------------
|
|
|
|
def _rating_domain(self):
|
|
""" Only take the published rating into account to compute avg and count """
|
|
return super()._rating_domain() & Domain('is_internal', '=', False)
|
|
|
|
def _get_images(self):
|
|
"""Return a list of records implementing `image.mixin` to
|
|
display on the carousel on the website for this template.
|
|
|
|
This returns a list and not a recordset because the records might be
|
|
from different models (template and image).
|
|
|
|
It contains in this order: the main image of the template and the
|
|
Template Extra Images.
|
|
"""
|
|
self.ensure_one()
|
|
return [self] + list(self.product_template_image_ids)
|
|
|
|
def _get_attribute_value_domain(self, attribute_value_dict):
|
|
return [
|
|
[('attribute_line_ids.value_ids', 'in', attribute_value_ids)]
|
|
for attribute_value_ids in attribute_value_dict.values()
|
|
]
|
|
|
|
@api.model
|
|
def _search_get_detail(self, website, order, options):
|
|
with_image = options['displayImage']
|
|
with_description = options['displayDescription']
|
|
with_category = options['displayExtraLink']
|
|
with_price = options['displayDetail']
|
|
domains = [website.sale_product_domain()]
|
|
category = options.get('category')
|
|
tags = options.get('tags')
|
|
min_price = options.get('min_price')
|
|
max_price = options.get('max_price')
|
|
attribute_value_dict = options.get('attribute_value_dict')
|
|
if category:
|
|
domains.append([('public_categ_ids', 'child_of', self.env['ir.http']._unslug(category)[1])])
|
|
if tags:
|
|
if isinstance(tags, str):
|
|
tags = tags.split(',')
|
|
tags = list(map(int, tags)) # Convert list of strings to list of integers
|
|
domains.append(Domain.OR([
|
|
Domain('product_tag_ids', 'in', tags),
|
|
Domain('product_variant_ids.additional_product_tag_ids', 'in', tags),
|
|
]))
|
|
if min_price:
|
|
domains.append([('list_price', '>=', min_price)])
|
|
if max_price:
|
|
domains.append([('list_price', '<=', max_price)])
|
|
if attribute_value_dict:
|
|
domains.extend(self._get_attribute_value_domain(attribute_value_dict))
|
|
search_fields = ['name', 'default_code', 'variants_default_code']
|
|
fetch_fields = ['id', 'name', 'website_url']
|
|
mapping = {
|
|
'name': {'name': 'name', 'type': 'text', 'match': True},
|
|
'default_code': {'name': 'default_code', 'type': 'text', 'match': True},
|
|
'product_variant_ids.default_code': {'name': 'product_variant_ids.default_code', 'type': 'text', 'match': True},
|
|
'website_url': {'name': 'website_url', 'type': 'text', 'truncate': False},
|
|
}
|
|
if with_image:
|
|
mapping['image_url'] = {'name': 'image_url', 'type': 'html'}
|
|
if with_description:
|
|
# Internal note is not part of the rendering.
|
|
search_fields.append('description')
|
|
fetch_fields.append('description')
|
|
search_fields.append('description_sale')
|
|
fetch_fields.append('description_sale')
|
|
mapping['description'] = {'name': 'description_sale', 'type': 'text', 'match': True}
|
|
if with_price:
|
|
mapping['detail'] = {'name': 'price', 'type': 'html', 'display_currency': options['display_currency']}
|
|
mapping['detail_strike'] = {'name': 'list_price', 'type': 'html', 'display_currency': options['display_currency']}
|
|
if with_category:
|
|
mapping['extra_link'] = {'name': 'category', 'type': 'html'}
|
|
return {
|
|
'model': 'product.template',
|
|
'base_domain': domains,
|
|
'search_fields': search_fields,
|
|
'fetch_fields': fetch_fields,
|
|
'mapping': mapping,
|
|
'icon': 'fa-shopping-cart',
|
|
}
|
|
|
|
def _search_render_results(self, fetch_fields, mapping, icon, limit):
|
|
with_image = 'image_url' in mapping
|
|
with_category = 'extra_link' in mapping
|
|
with_price = 'detail' in mapping
|
|
results_data = super()._search_render_results(fetch_fields, mapping, icon, limit)
|
|
current_website = self.env['website'].get_current_website()
|
|
for product, data in zip(self, results_data):
|
|
categ_ids = product.public_categ_ids.filtered(lambda c: not c.website_id or c.website_id == current_website)
|
|
if with_price:
|
|
combination_info = product._get_combination_info(only_template=True)
|
|
data['price'], list_price = self._search_render_results_prices(
|
|
mapping, combination_info
|
|
)
|
|
if list_price:
|
|
data['list_price'] = list_price
|
|
|
|
if with_image:
|
|
data['image_url'] = '/web/image/product.template/%s/image_128' % data['id']
|
|
if with_category and categ_ids:
|
|
data['category'] = self.env['ir.ui.view'].sudo()._render_template(
|
|
"website_sale.product_category_extra_link",
|
|
{
|
|
'categories': categ_ids,
|
|
'slug': self.env['ir.http']._slug,
|
|
'shop_path': SHOP_PATH,
|
|
}
|
|
)
|
|
return results_data
|
|
|
|
def _search_render_results_prices(self, mapping, combination_info):
|
|
if combination_info.get('prevent_zero_price_sale'):
|
|
return None, None
|
|
|
|
monetary_options = {'display_currency': mapping['detail']['display_currency']}
|
|
price = self.env['ir.qweb.field.monetary'].value_to_html(
|
|
combination_info['price'], monetary_options
|
|
)
|
|
list_price = None
|
|
if combination_info['has_discounted_price']:
|
|
list_price = self.env['ir.qweb.field.monetary'].value_to_html(
|
|
combination_info['list_price'], monetary_options
|
|
)
|
|
if combination_info.get('compare_list_price'):
|
|
list_price = self.env['ir.qweb.field.monetary'].value_to_html(
|
|
combination_info['compare_list_price'], monetary_options
|
|
)
|
|
|
|
return price, list_price
|
|
|
|
def _get_google_analytics_data(self, product, combination_info):
|
|
self.ensure_one()
|
|
return {
|
|
'item_id': product.barcode or product.id,
|
|
'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 """
|
|
pricelist = super()._get_contextual_pricelist()
|
|
if request and request.is_frontend and not pricelist:
|
|
return request.pricelist
|
|
return pricelist
|
|
|
|
def _website_show_quick_add(self):
|
|
self.ensure_one()
|
|
if not self.filtered_domain(self.env['website']._product_domain()):
|
|
return False
|
|
return not request.website.prevent_zero_price_sale or self._get_contextual_price()
|
|
|
|
@api.model
|
|
def _get_configurator_display_price(
|
|
self, product_or_template, quantity, date, currency, pricelist, **kwargs
|
|
):
|
|
""" Override of `sale` to apply taxes.
|
|
|
|
:param product.product|product.template product_or_template: The product for which to get
|
|
the price.
|
|
:param int quantity: The quantity of the product.
|
|
:param datetime date: The date to use to compute the price.
|
|
:param res.currency currency: The currency to use to compute the price.
|
|
:param product.pricelist pricelist: The pricelist to use to compute the price.
|
|
:param dict kwargs: Locally unused data passed to `super`.
|
|
:rtype: tuple(float, int or False)
|
|
:return: The specified product's display price (and the applied pricelist rule)
|
|
"""
|
|
price, pricelist_rule_id = super()._get_configurator_display_price(
|
|
product_or_template, quantity, date, currency, pricelist, **kwargs
|
|
)
|
|
|
|
if website := ir_http.get_request_website():
|
|
product_taxes = product_or_template.sudo().taxes_id._filter_taxes_by_company(
|
|
self.env.company
|
|
)
|
|
if product_taxes:
|
|
taxes = request.fiscal_position.map_tax(product_taxes)
|
|
return self._apply_taxes_to_price(
|
|
price, currency, product_taxes, taxes, product_or_template, website=website
|
|
), pricelist_rule_id
|
|
return price, pricelist_rule_id
|
|
|
|
def _to_markup_data(self, website):
|
|
""" Generate JSON-LD markup data for the current product template.
|
|
|
|
If the template has multiple variants, the https://schema.org/ProductGroup schema is used.
|
|
Otherwise, the markup data generation is delegated to the variant to use the
|
|
https://schema.org/Product schema.
|
|
|
|
:param website website: The current website.
|
|
:return: The JSON-LD markup data.
|
|
:rtype: dict
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if self.product_variant_count == 1:
|
|
return self.product_variant_id._to_markup_data(website)
|
|
|
|
# perf: temporal solution to avoid slowness when product have many variants and pricelist rules
|
|
limit = self.env['ir.config_parameter'].sudo().get_param('website_sale.markup_data_limit_variants', False)
|
|
if limit:
|
|
product_variant_ids = self.product_variant_ids[:int(limit)]
|
|
else:
|
|
product_variant_ids = self.product_variant_ids
|
|
|
|
base_url = website.get_base_url()
|
|
markup_data = {
|
|
'@context': 'https://schema.org/',
|
|
'@type': 'ProductGroup',
|
|
'name': self.name,
|
|
'image': f'{base_url}{website.image_url(self, "image_1920")}',
|
|
'url': f'{base_url}{self.website_url}',
|
|
'hasVariant': [product._to_markup_data(website) for product in product_variant_ids]
|
|
}
|
|
if self.description_ecommerce:
|
|
markup_data['description'] = text_from_html(self.description_ecommerce)
|
|
return markup_data
|
|
|
|
def _get_ribbon(self, price_vals=None, auto_assign_ribbons=None, variant=None):
|
|
"""Return the ribbon to display for the current template.
|
|
|
|
It'll be either the ribbon set on the first variant, or the template, or the first
|
|
applicable ribbon in the automatically assigned ribbons.
|
|
|
|
:param dict price_vals: price values for the current product
|
|
:param auto_assign_ribbons: automatically assigned recordsets, as a `product.ribbon`
|
|
recordset
|
|
:param product.product variant: if any, the displayed variant whose ribbon we're looking
|
|
for.
|
|
|
|
:returns: the ribbon to display, if there is one.
|
|
:rtype: `product.ribbon` recordset
|
|
"""
|
|
variant = variant or self.product_variant_id
|
|
ribbon = variant.sudo().variant_ribbon_id or self.sudo().website_ribbon_id
|
|
if not ribbon:
|
|
# The None check ensures that we do not recompute the ribbons when no ribbons were
|
|
# previously found.
|
|
if auto_assign_ribbons is None:
|
|
# On product page, the auto_assign_ribbons are not provided.
|
|
auto_assign_ribbons = self.env['product.ribbon'].search_fetch([
|
|
('assign', '!=', 'manual'),
|
|
])
|
|
for rb in auto_assign_ribbons:
|
|
if rb._is_applicable_for(variant, price_vals):
|
|
return rb
|
|
|
|
return ribbon
|
|
|
|
def _get_access_action(self, access_uid=None, force_website=False):
|
|
""" Instead of the classic form view, redirect to website if it is published. """
|
|
self.ensure_one()
|
|
if force_website or (self.website_published and self.env.user.share):
|
|
return {
|
|
"type": "ir.actions.act_url",
|
|
"url": self.website_url,
|
|
"target": "self",
|
|
"target_type": "public",
|
|
}
|
|
return super()._get_access_action(access_uid=access_uid, force_website=force_website)
|
|
|
|
@api.model
|
|
def _allow_publish_rating_stats(self):
|
|
return True
|
|
|
|
def _get_product_url(self, category=None, query_params=None, grouped_attributes_values=None):
|
|
self.ensure_one()
|
|
slug = self.env['ir.http']._slug
|
|
|
|
url = (category and f'/shop/{slug(category)}/{slug(self)}') or self.website_url
|
|
|
|
query_params = query_params or {}
|
|
if grouped_attributes_values:
|
|
product_grouped_values = self.attribute_line_ids.value_ids.grouped('attribute_id')
|
|
available_pav_ids = [
|
|
next(v.id for v in pavs if v in product_grouped_values[pa])
|
|
for pa, pavs in grouped_attributes_values.items()
|
|
if pa in product_grouped_values
|
|
]
|
|
available_pav_ids.sort()
|
|
query_params['attribute_values'] = ','.join(str(i) for i in available_pav_ids)
|
|
|
|
if query_params:
|
|
url = f'{url}?{urls.url_encode(query_params)}'
|
|
|
|
return url
|