19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -1,26 +1,37 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_move
from . import crm_team
from . import delivery_carrier
from . import digest
from . import ir_http
from . import payment_provider
from . import ir_module_module
from . import payment_token
from . import product_attribute
from . import product_document
from . import product_feed
from . import product_image
from . import product_pricelist
from . import product_pricelist_item
from . import product_product
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
from . import res_partner
from . import sale_order
from . import sale_order_line
from . import theme_utils
from . import website
from . import website_base_unit
from . import website_checkout_step
from . import website_menu
from . import website_page
from . import website_sale_extra_field
from . import website_snippet_filter
from . import website_track
from . import website_visitor

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models

View file

@ -1,39 +1,33 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from dateutil.relativedelta import relativedelta
from odoo import fields,api, models, _
from odoo.exceptions import UserError, ValidationError
from odoo import _, fields, models
class CrmTeam(models.Model):
_inherit = "crm.team"
_inherit = 'crm.team'
website_ids = fields.One2many('website', 'salesteam_id', string='Websites')
abandoned_carts_count = fields.Integer(
compute='_compute_abandoned_carts',
string='Number of Abandoned Carts', readonly=True)
website_ids = fields.One2many(
string="Websites", comodel_name='website', inverse_name='salesteam_id',
)
abandoned_carts_amount = fields.Integer(
compute='_compute_abandoned_carts',
string='Amount of Abandoned Carts', readonly=True)
string="Amount of Abandoned Carts", compute='_compute_abandoned_carts',
)
abandoned_carts_count = fields.Integer(
string="Number of Abandoned Carts", compute='_compute_abandoned_carts',
)
def _compute_abandoned_carts(self):
# abandoned carts to recover are draft sales orders that have no order lines,
# a partner other than the public user, and created over an hour ago
# and the recovery mail was not yet sent
counts = {}
amounts = {}
website_teams = self.filtered(lambda team: team.website_ids)
if website_teams:
abandoned_carts_data = self.env['sale.order']._read_group([
('is_abandoned_cart', '=', True),
('cart_recovery_email_sent', '=', False),
('team_id', 'in', website_teams.ids),
], ['amount_total', 'team_id'], ['team_id'])
counts = {data['team_id'][0]: data['team_id_count'] for data in abandoned_carts_data}
amounts = {data['team_id'][0]: data['amount_total'] for data in abandoned_carts_data}
abandoned_carts_data = self.env['sale.order']._read_group([
('is_abandoned_cart', '=', True),
('cart_recovery_email_sent', '=', False),
('team_id', 'in', website_teams.ids),
], ['team_id'], ['amount_total:sum', '__count'])
counts = {team.id: count for team, __, count in abandoned_carts_data}
amounts = {team.id: amount_total_sum for team, amount_total_sum, __ in abandoned_carts_data}
for team in self:
team.abandoned_carts_count = counts.get(team.id, 0)
team.abandoned_carts_amount = amounts.get(team.id, 0)
@ -43,7 +37,7 @@ class CrmTeam(models.Model):
return {
'name': _('Abandoned Carts'),
'type': 'ir.actions.act_window',
'view_mode': 'tree,form',
'view_mode': 'list,form',
'domain': [('is_abandoned_cart', '=', True)],
'search_view_id': [self.env.ref('sale.sale_order_view_search_inherit_sale').id],
'context': {

View file

@ -0,0 +1,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class DeliveryCarrier(models.Model):
_name = 'delivery.carrier'
_inherit = ['delivery.carrier', 'website.published.multi.mixin']
website_description = fields.Text(
string="Description for Online Quotations",
related='product_id.description_sale',
readonly=False,
)

View file

@ -1,34 +1,28 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo import _, fields, models
from odoo.exceptions import AccessError
class Digest(models.Model):
class DigestDigest(models.Model):
_inherit = 'digest.digest'
kpi_website_sale_total = fields.Boolean('eCommerce Sales')
kpi_website_sale_total = fields.Boolean(string="eCommerce Sales")
kpi_website_sale_total_value = fields.Monetary(compute='_compute_kpi_website_sale_total_value')
def _compute_kpi_website_sale_total_value(self):
if not self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
raise AccessError(_("Do not have access, skip this data for user's digest email"))
for record in self:
start, end, company = record._get_kpi_compute_parameters()
confirmed_website_sales = self.env['sale.order'].search([
('date_order', '>=', start),
('date_order', '<', end),
('state', 'not in', ['draft', 'cancel', 'sent']),
('website_id', '!=', False),
('company_id', '=', company.id)
])
record.kpi_website_sale_total_value = sum(
sale.currency_id._convert(sale.amount_total, company.currency_id, company, sale.date_order)
for sale in confirmed_website_sales
)
self._calculate_company_based_kpi(
'sale.report',
'kpi_website_sale_total_value',
date_field='date',
additional_domain=[('state', 'not in', ['draft', 'cancel', 'sent']), ('website_id', '!=', False)],
sum_field='price_subtotal',
)
def _compute_kpis_actions(self, company, user):
res = super(Digest, self)._compute_kpis_actions(company, user)
res['kpi_website_sale_total'] = 'website.backend_dashboard&menu_id=%s' % self.env.ref('website.menu_website_configuration').id
res = super()._compute_kpis_actions(company, user)
res['kpi_website_sale_total'] = 'website.backend_dashboard?menu_id=%s' % self.env.ref('website.menu_website_configuration').id
return res

View file

@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo import api, models
from odoo.http import request
from odoo.tools import lazy
class IrHttp(models.AbstractModel):
@ -13,3 +14,21 @@ class IrHttp(models.AbstractModel):
affiliate_id = request.httprequest.args.get('affiliate_id')
if affiliate_id:
request.session['affiliate_id'] = int(affiliate_id)
@api.model
def get_frontend_session_info(self):
session_info = super().get_frontend_session_info()
session_info.update({
'add_to_cart_action': request.website.add_to_cart_action,
})
return session_info
@classmethod
def _frontend_pre_dispatch(cls):
super()._frontend_pre_dispatch()
# lazy to make sure those are only evaluated when requested
# All those records are sudoed !
request.cart = lazy(request.website._get_and_cache_current_cart)
request.fiscal_position = lazy(request.website._get_and_cache_current_fiscal_position)
request.pricelist = lazy(request.website._get_and_cache_current_pricelist)

View file

@ -0,0 +1,59 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.tools import SQL, split_every
class IrModuleModule(models.Model):
_inherit = 'ir.module.module'
@api.model
def _load_module_terms(self, modules, langs, overwrite=False):
# Add missing website_sale-specific translations
super()._load_module_terms(modules, langs, overwrite=overwrite)
to_langs = [lang for lang in langs if lang != 'en_US']
if not (to_langs and modules):
return # nothing to translate
def set_field(fname):
lang_items = (
SQL('%(lang)s, o_step.%(fname)s->>%(lang)s', lang=lang, fname=fname)
for lang in to_langs
)
# PSQL functions take 100 args max, and we're generating 2 per lang
batched_lang_items = split_every(50, lang_items)
update_jsonb = SQL(' || ').join(
SQL('jsonb_build_object(%s)', SQL(', ').join(batch))
for batch in batched_lang_items
)
ordered = reversed if overwrite else iter
src = SQL(' || ').join(ordered([
SQL('jsonb_strip_nulls(%s)', update_jsonb), # gets updated translation
SQL('jsonb_strip_nulls(step.%s)', fname), # keeps current translation
]))
return SQL('%(fname)s = %(src)s', fname=fname, src=src)
WebsiteCheckoutStep = self.env['website.checkout.step']
to_translate = [
SQL.identifier(field.name)
for field in WebsiteCheckoutStep._fields.values()
if field.translate is True # more correct in case of `callable(field.translate)`
]
set_fields = SQL(', ').join(set_field(fname) for fname in to_translate)
WebsiteCheckoutStep.invalidate_model()
self.env.cr.execute(SQL(
'''
UPDATE website_checkout_step step
SET %(set_fields)s
FROM website_checkout_step o_step
JOIN website_checkout_step s_step
ON o_step.step_href = s_step.step_href
WHERE o_step.website_id IS NULL
AND s_step.website_id IS NOT NULL
AND step.id = s_step.id
''',
set_fields=set_fields,
))

View file

@ -1,25 +0,0 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class PaymentProvider(models.Model):
_inherit = 'payment.provider'
@api.model
def _get_compatible_providers(self, *args, website_id=None, **kwargs):
""" Override of payment to only return providers matching website-specific criteria.
In addition to the base criteria, the website must either not be set or be the same as the
one provided in the kwargs.
:param int website_id: The provided website, as a `website` id
:return: The compatible providers
:rtype: recordset of `payment.provider`
"""
providers = super()._get_compatible_providers(*args, website_id=website_id, **kwargs)
if website_id:
providers = providers.filtered(
lambda p: not p.website_id or p.website_id.id == website_id
)
return providers

View file

@ -0,0 +1,21 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class PaymentToken(models.Model):
_inherit = 'payment.token'
def _get_available_tokens(self, *args, is_express_checkout=False, **kwargs):
""" Override of `payment` not to return the tokens in case of express checkout.
:param dict args: Locally unused arguments.
:param bool is_express_checkout: Whether the payment is made through express checkout.
:param dict kwargs: Locally unused keywords arguments.
:return: The available tokens.
:rtype: payment.token
"""
if is_express_checkout:
return self.env['payment.token']
return super()._get_available_tokens(*args, **kwargs)

View file

@ -1,32 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import OrderedDict
from odoo import models, fields
from odoo import api, fields, models
class ProductAttribute(models.Model):
_inherit = 'product.attribute'
visibility = fields.Selection([('visible', 'Visible'), ('hidden', 'Hidden')], default='visible')
visibility = fields.Selection(
selection=[('visible', "Visible"), ('hidden', "Hidden")],
default='visible',
)
preview_variants = fields.Selection(
string="On Product Cards",
selection=[
('visible', "Visible"),
('hidden', "Hidden"),
('hover', "Hover"),
],
default='hidden',
help="Instantly created variants are available for selection from your /shop page.",
)
is_thumbnail_visible = fields.Boolean(
string="Show Thumbnails",
help="Use product variant images instead of the attribute values displays."
)
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.
@api.onchange('create_variant', 'display_type')
def _onchange_disable_preview_variants(self):
""" The option to preview variants is only available for instantly created single variants.
"""
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
if self.create_variant != 'always' or self.display_type == 'multi':
self.preview_variants = 'hidden'
self.is_thumbnail_visible = False

View file

@ -0,0 +1,20 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class ProductDocument(models.Model):
_inherit = 'product.document'
shown_on_product_page = fields.Boolean(string="Publish on website")
@api.constrains('res_model', 'shown_on_product_page')
def _unsupported_product_product_document_on_ecommerce(self):
# Not supported for now because product page is dynamic and it would require a lot of work
# to update documents shown according to combination. It'll wait for planned tasks
# rebuilding the product page & variant mixin.
for document in self:
if document.res_model == 'product.product' and document.shown_on_product_page:
raise ValidationError(
_("Documents shown on product page cannot be restricted to a specific variant"))

View file

@ -0,0 +1,403 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import gzip
import uuid
from dateutil.relativedelta import relativedelta
from werkzeug.urls import url_encode, url_parse
from odoo import SUPERUSER_ID, api, fields, models
from odoo.exceptions import ValidationError
from odoo.fields import Domain
from odoo.http import request
from odoo.tools import float_is_zero, float_round, urls
from odoo.addons.website_sale import const, utils
class ProductFeed(models.Model):
_name = 'product.feed'
_inherit = ['mail.thread']
_description = "Product Feed"
name = fields.Char(required=True)
website_id = fields.Many2one('website', required=True)
pricelist_id = fields.Many2one(
'product.pricelist',
help="Specify a pricelist to localize the feed with a specific currency."
" If not set, the default website pricelist will be used."
"\nNote that the pricelist must be selectable on the website.",
domain="[('website_id', 'in', (False, website_id)), ('selectable', '=', True)]",
)
lang_id = fields.Many2one(
'res.lang',
string="Language",
help="Select the language to translate product names, descriptions,"
" and other text in the feed.",
compute='_compute_lang_id',
precompute=True,
store=True,
readonly=False,
required=True,
domain="[('id', 'in', website_lang_ids)]",
)
website_lang_ids = fields.Many2many(related='website_id.language_ids')
product_category_ids = fields.Many2many('product.public.category', string="Categories")
target = fields.Selection(
selection=[
('gmc', "Google Merchant Center"),
],
required=True,
default='gmc',
)
access_token = fields.Char(
readonly=True,
required=True,
default=lambda _: uuid.uuid4().hex,
copy=False,
)
url = fields.Char(compute='_compute_url')
last_notification_date = fields.Date()
# Caching mechanism (technical fields)
feed_cache = fields.Binary(compute='_compute_feed_cache', store=True, readonly=True)
cache_expiry = fields.Datetime(readonly=True, required=True, default=fields.Datetime.now)
@api.depends('target')
def _compute_url(self):
"""Compute the full feed url."""
for feed in self:
match feed.target:
case 'gmc':
path = '/gmc.xml'
case _:
raise NotImplementedError
feed.url = urls.urljoin(
feed.website_id.get_base_url(),
f'{path}?feed_id={feed.id}&access_token={feed.access_token}',
)
@api.depends('website_id')
def _compute_lang_id(self):
for feed in self.filtered(lambda f: not f.lang_id):
feed.lang_id = feed.website_id.default_lang_id
@api.depends(
'website_id', 'pricelist_id', 'lang_id', 'product_category_ids'
)
def _compute_feed_cache(self):
"""Invalidate cache on feed parameter changes."""
self.action_invalidate_cache()
@api.constrains('product_category_ids', 'website_id')
def _check_product_limit(self):
"""Add a soft limit on the number of products a feed can contain.
A strong limit of 6000 is applied during the feed rendering phase.
"""
for feed in self:
product_count = feed.env['product.product'].search_count(
feed._get_feed_product_domain(), limit=const.PRODUCT_FEED_SOFT_LIMIT + 1
)
if product_count > const.PRODUCT_FEED_SOFT_LIMIT:
raise ValidationError(feed.env._(
"A single feed cannot contain more than %(limit)s products."
" Please separate products with Categories.",
limit=f"{const.PRODUCT_FEED_SOFT_LIMIT:,}", # Format to 5,000
))
def action_invalidate_cache(self):
self.cache_expiry = fields.Datetime.now() - relativedelta(days=1)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'message': self.env._("Feed cache successfully reset."),
},
}
def _render_and_cache_compressed_gmc_feed(self):
"""Render and cache the Google Merchant Center feed.
This method ensures that the feed is rendered only once per day and caches the result. If
the feed parameters change, the cache is invalidated, and the feed is re-rendered.
:raises LockError: If the feed is already being rendered by another request.
:return: The rendered feed compressed using gzip.
:rtype: bytes
"""
self.ensure_one()
if not self.feed_cache or self.cache_expiry < fields.Datetime.now():
# Lock the record to prevent concurrent rendering
self.lock_for_update()
gmc_xml = self._render_gmc_feed()
compressed_gmc_xml = gzip.compress(gmc_xml.encode())
# The binary field stores the data in the `datas` field of an `ir.attachment` which is a
# base64 view of its `raw` data, therefore we encode the gzip content before saving it.
self.feed_cache = base64.b64encode(compressed_gmc_xml)
self.cache_expiry = fields.Datetime.today() + relativedelta(days=1)
return compressed_gmc_xml # Avoid encoding and directly decoding
return base64.b64decode(self.feed_cache)
def _render_gmc_feed(self):
"""Render the Google Merchant Center feed.
See also https://support.google.com/merchants/answer/7052112 for the XML format.
:return: The rendered XML feed.
:rtype: str
"""
self.ensure_one()
# Set the language context for rendering.
# Ensures all links, product names, descriptions, etc., are localized.
self = self.with_context(lang=self.lang_id.code) # noqa: PLW0642
# Override the pricelist of the request to localize the currency and prices, otherwise, uses
# the website default pricelist.
if self.pricelist_id:
request.pricelist = self.pricelist_id
homepage_url = self.website_id.homepage_url or '/'
website_homepage = self.website_id._get_website_pages(
Domain([('url', '=', homepage_url), ('website_id', '!=', False)]), limit=1
)
gmc_data = {
'title': website_homepage.website_meta_title or self.website_id.name,
'link': urls.urljoin(
self.website_id.get_base_url(),
self.env['ir.http']._url_lang(homepage_url, lang_code=self.lang_id.code),
),
'description': website_homepage.website_meta_description or self.website_id,
'items': self._prepare_gmc_items(),
}
return self.env['ir.ui.view'].sudo()._render_template(
'website_sale.gmc_xml', gmc_data,
)
def _prepare_gmc_items(self):
"""Prepare Google Merchant Center items' fields.
See Google's (https://support.google.com/merchants/answer/7052112) documentation for more
information about each field.
:return: a dictionary for each product in this recordset.
:rtype: list[dict]
"""
products = self._get_feed_products()
base_url = self.website_id.get_base_url()
def format_product_link(url_):
if self.pricelist_id:
parsed_url = url_parse(url_)
query = parsed_url.decode_query()
query['pricelist'] = self.pricelist_id.id
url_ = parsed_url._replace(query=url_encode(query)).to_url()
return urls.urljoin(
base_url, self.env['ir.http']._url_lang(url_, lang_code=self.lang_id.code)
)
return {
product: {
'id': product.default_code or product.id,
'title': product.with_context(display_default_code=False).display_name,
'description': product.website_meta_description or product.description_sale,
'link': format_product_link(product.website_url),
**self._prepare_gmc_identifier(product),
**self._prepare_gmc_image_links(product, base_url),
**price_info,
**self._prepare_gmc_stock_info(product),
**self._prepare_gmc_additional_info(product),
}
for product in products
if product._is_variant_possible()
and (price_info := self._prepare_gmc_price_info(product))
}
def _get_feed_product_domain(self):
product_domain = self.website_id._get_basic_feed_product_domain()
if self.product_category_ids:
product_domain &= Domain('public_categ_ids', 'child_of', self.product_category_ids.ids)
return product_domain
def _get_feed_products(self):
product_domain = self._get_feed_product_domain()
products = self.env['product.product'].search(
product_domain, limit=const.PRODUCT_FEED_HARD_LIMIT
)
# Send an early warning to the website manager if the number of products exceeds the
# midpoint between the soft and hard limit.
if len(products) > (const.PRODUCT_FEED_SOFT_LIMIT + const.PRODUCT_FEED_HARD_LIMIT) / 2:
today = fields.Date.today()
if (
not self.last_notification_date
or relativedelta(today, self.last_notification_date).weeks > 0
):
self._notify_website_manager(
subject=self.env._("GMC: Product Limit Exceeded"),
body=self.env._(
"The feed %(feed_name)s contains more than %(limit)s products,"
" which may not be fully updated. Consider refining the feed by adjusting"
" the product categories.",
feed_name=self.display_name,
limit=f"{const.PRODUCT_FEED_SOFT_LIMIT:,}", # Format to 5,000
),
)
self.last_notification_date = today
return products
def _prepare_gmc_identifier(self, product):
"""Prepare the product identifiers for Google Merchant Center.
:return: The barcode of the product as GTIN
:rtype: dict
"""
if product.barcode:
return {'gtin': product.barcode, 'identifier_exists': 'yes'}
return {'identifier_exists': 'no'}
def _prepare_gmc_image_links(self, product, base_url):
"""Prepare the product image links for Google Merchant Center.
:return: The main product image link, and the extra images. No videos.
:rtype: dict
"""
return {
# Don't send any image link if there isn't. Google does not allow placeholder
'image_link': (
urls.urljoin(base_url, product._get_image_1920_url()) if product.image_128 else ''
),
# Supports up to 10 extra images
'additional_image_link': [
urls.urljoin(base_url, url) for url in product._get_extra_image_1920_urls()[:10]
],
}
def _prepare_gmc_price_info(self, product):
"""Prepare price-related information for Google Merchant Center.
Note: If the product is flagged to prevent zero price sales, an empty dictionary is
returned.
:return: A dictionary containing nothing if the product is "prevent zero price sale", or:
- List price,
- Sale price (if applicable), and
- Comparison prices (e.g., $100 / ml) if "Product Reference Price" is enabled.
:rtype: dict
"""
price_context = product._get_product_price_context(
product.product_template_attribute_value_ids
)
combination_info = product.with_context(
**price_context,
).product_tmpl_id._get_additionnal_combination_info(
product,
quantity=1.0,
uom=product.uom_id,
date=fields.Date.context_today(self),
website=self.website_id,
)
if combination_info['prevent_zero_price_sale']:
return {}
price_info = {
'price': utils.gmc_format_price(
combination_info['list_price'], combination_info['currency'],
),
}
if combination_info['has_discounted_price']:
price_info['sale_price'] = utils.gmc_format_price(
combination_info['price'], combination_info['currency'],
)
start_date = combination_info['discount_start_date']
end_date = combination_info['discount_end_date']
if start_date and end_date:
price_info['sale_price_effective_date'] = '/'.join(
map(utils.gmc_format_date, (start_date, end_date)),
)
# Note: Google only supports a restricted set of unit and computes the comparison prices
# differently than Odoo.
# Ex: product="Pack of wine (6 bottles)", price=$65.00, uom_name="Pack".
# - in odoo: base_unit_count=6.0, base_unit_name="750ml"
# => displayed: "$10.83 / 750ml"
# - in google: unit_pricing_measure="4500ml", unit_pricing_base_measure="750ml"
# => displayed: "$10.83 / 750ml"
if (
combination_info.get('base_unit_name')
and product.base_unit_count
and (match := const.GMC_BASE_MEASURE.match(
combination_info['base_unit_name'].strip().lower()
))
):
base_count, base_unit = match['base_count'] or '1', match['base_unit']
count = product.base_unit_count * int(base_count)
if (
base_unit in const.GMC_SUPPORTED_UOM
and not float_is_zero(count, precision_digits=2)
):
price_info['unit_pricing_measure'] = (
f'{float_round(count, precision_digits=2)}{base_unit}'
)
price_info['unit_pricing_base_measure'] = f'{base_count}{base_unit}'
return price_info
def _prepare_gmc_stock_info(self, _product):
"""Intended to be overridden in stock."""
return {'availability': 'in_stock'}
def _prepare_gmc_additional_info(self, product):
additional_info = {
'product_detail': [
(attr.attribute_id.name, attr.name)
for attr in product.product_template_attribute_value_ids
],
'is_bundle': 'yes' if product.type == 'combo' else 'no',
'product_type': [
category.replace('/', '>') # Google uses a different format
for category in (
# Up to 5 categories
product.public_categ_ids.sorted('sequence').mapped('display_name')[:5]
)
],
'custom_label': [
(f'custom_label_{i}', tag_name)
for i, tag_name in enumerate(
# Supports up to 5 custom labels
product.all_product_tag_ids.sorted('sequence').mapped('name')[:5]
)
],
}
# Link variants together
if len(product.product_tmpl_id.product_variant_ids) > 1:
additional_info['item_group_id'] = product.product_tmpl_id.id
return additional_info
def _notify_website_manager(self, **kwargs):
"""Send a notification to the website manager using OdooBot.
This method wraps around `message_notify` to notify the manager of the feed's website.
:param dict kwargs: Additional arguments passed to `message_notify`.
:return: The created `mail.message` record.
:rtype: mail.message
"""
return self.with_user(SUPERUSER_ID).message_notify(
partner_ids=self.website_id.salesperson_id.partner_id.ids, **kwargs
)

View file

@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from odoo import api, fields, models, tools, _
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.image import is_image_size_above
from odoo.addons.web_editor.tools import get_video_embed_code, get_video_thumbnail
from odoo.addons.html_editor.tools import get_video_embed_code, get_video_thumbnail
class ProductImage(models.Model):
@ -15,23 +15,42 @@ class ProductImage(models.Model):
_inherit = ['image.mixin']
_order = 'sequence, id'
name = fields.Char("Name", required=True)
name = fields.Char(string="Name", required=True)
sequence = fields.Integer(default=10)
image_1920 = fields.Image()
product_tmpl_id = fields.Many2one('product.template', "Product Template", index=True, ondelete='cascade')
product_variant_id = fields.Many2one('product.product', "Product Variant", index=True, ondelete='cascade')
video_url = fields.Char('Video URL',
help='URL of a video for showcasing your product.')
embed_code = fields.Html(compute="_compute_embed_code", sanitize=False)
product_tmpl_id = fields.Many2one(
string="Product Template", comodel_name='product.template', ondelete='cascade', index=True,
)
product_variant_id = fields.Many2one(
string="Product Variant", comodel_name='product.product', ondelete='cascade', index=True,
)
video_url = fields.Char(
string="Video URL",
help="URL of a video for showcasing your product.",
)
embed_code = fields.Html(compute='_compute_embed_code', sanitize=False)
can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed', store=True)
can_image_1024_be_zoomed = fields.Boolean(
string="Can Image 1024 be zoomed",
compute='_compute_can_image_1024_be_zoomed',
store=True,
)
#=== COMPUTE METHODS ===#
@api.depends('image_1920', 'image_1024')
def _compute_can_image_1024_be_zoomed(self):
for image in self:
image.can_image_1024_be_zoomed = image.image_1920 and tools.is_image_size_above(image.image_1920, image.image_1024)
image.can_image_1024_be_zoomed = image.image_1920 and is_image_size_above(image.image_1920, image.image_1024)
@api.depends('video_url')
def _compute_embed_code(self):
for image in self:
image.embed_code = image.video_url and get_video_embed_code(image.video_url) or False
#=== ONCHANGE METHODS ===#
@api.onchange('video_url')
def _onchange_video_url(self):
@ -39,10 +58,7 @@ class ProductImage(models.Model):
thumbnail = get_video_thumbnail(self.video_url)
self.image_1920 = thumbnail and base64.b64encode(thumbnail) or False
@api.depends('video_url')
def _compute_embed_code(self):
for image in self:
image.embed_code = get_video_embed_code(image.video_url) or False
#=== CONSTRAINT METHODS ===#
@api.constrains('video_url')
def _check_valid_video_url(self):
@ -50,6 +66,8 @@ class ProductImage(models.Model):
if image.video_url and not image.embed_code:
raise ValidationError(_("Provided video URL for '%s' is not valid. Please enter a valid video URL.", image.name))
#=== CRUD METHODS ===#
@api.model_create_multi
def create(self, vals_list):
"""

View file

@ -1,28 +1,59 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.addons.website.models import ir_http
class ProductPricelist(models.Model):
_inherit = "product.pricelist"
_inherit = 'product.pricelist'
#=== DEFAULT METHODS ===#
def _default_website(self):
""" Find the first company's website, if there is one. """
company_id = self.env.company.id
if self._context.get('default_company_id'):
company_id = self._context.get('default_company_id')
if self.env.context.get('default_company_id'):
company_id = self.env.context.get('default_company_id')
domain = [('company_id', '=', company_id)]
return self.env['website'].search(domain, limit=1)
website_id = fields.Many2one('website', string="Website", ondelete='restrict', default=_default_website, domain="[('company_id', '=?', company_id)]")
code = fields.Char(string='E-commerce Promotional Code', groups="base.group_user")
#=== FIELDS ===#
website_id = fields.Many2one(
string="Website",
comodel_name='website',
ondelete='restrict',
default=_default_website,
domain="[('company_id', '=?', company_id)]",
tracking=20,
help="If you want a pricelist to be available on a website,"
"you must fill in this field or make it selectable."
"Otherwise, the pricelist will not apply to any website."
)
code = fields.Char(string="E-commerce Promotional Code", groups='base.group_user')
selectable = fields.Boolean(help="Allow the end user to choose this price list")
#=== CONSTRAINT METHODS ===#
@api.constrains('company_id', 'website_id')
def _check_websites_in_company(self):
""" Prevent misconfiguration multi-website/multi-companies.
If the record has a company, the website should be from that company.
"""
for record in self.filtered(lambda pl: pl.website_id and pl.company_id):
if record.website_id.company_id != record.company_id:
raise ValidationError(_(
"Only the company's websites are allowed."
"\nLeave the Company field empty or select a website from that company."
))
#=== CRUD METHODS ===#
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
@ -34,22 +65,22 @@ class ProductPricelist(models.Model):
# It be set when we actually create the pricelist
self = self.with_context(default_company_id=vals['company_id'])
pricelists = super().create(vals_list)
pricelists and pricelists.clear_caches()
if pricelists:
self.env.registry.clear_cache()
return pricelists
def write(self, data):
res = super(ProductPricelist, self).write(data)
if data.keys() & {'code', 'active', 'website_id', 'selectable', 'company_id'}:
self._check_website_pricelist()
self and self.clear_caches()
def write(self, vals):
res = super().write(vals)
self and self.env.registry.clear_cache()
return res
def unlink(self):
res = super(ProductPricelist, self).unlink()
self._check_website_pricelist()
self and self.clear_caches()
res = super().unlink()
self and self.env.registry.clear_cache()
return res
#=== BUSINESS METHODS ===#
def _get_partner_pricelist_multi_search_domain_hook(self, company_id):
domain = super()._get_partner_pricelist_multi_search_domain_hook(company_id)
website = ir_http.get_request_website()
@ -64,12 +95,6 @@ class ProductPricelist(models.Model):
res = res.filtered(lambda pl: pl._is_available_on_website(website))
return res
def _check_website_pricelist(self):
for website in self.env['website'].search([]):
# sudo() to be able to read pricelists/website from another company
if not website.sudo().pricelist_ids:
raise UserError(_("With this action, '%s' website would not have any pricelist available.") % (website.name))
def _is_available_on_website(self, website):
""" To be able to be used on a website, a pricelist should either:
- Have its `website_id` set to current website (specific pricelist).
@ -85,7 +110,7 @@ class ProductPricelist(models.Model):
self.ensure_one()
if self.company_id and self.company_id != website.company_id:
return False
return self.website_id.id == website.id or (not self.website_id and (self.selectable or self.sudo().code))
return self.active and self.website_id.id == website.id or (not self.website_id and (self.selectable or self.sudo().code))
def _is_available_in_country(self, country_code):
self.ensure_one()
@ -104,12 +129,3 @@ class ProductPricelist(models.Model):
'&', ('website_id', '=', False),
'|', ('selectable', '=', True), ('code', '!=', False),
]
@api.constrains('company_id', 'website_id')
def _check_websites_in_company(self):
'''Prevent misconfiguration multi-website/multi-companies.
If the record has a company, the website should be from that company.
'''
for record in self.filtered(lambda pl: pl.website_id and pl.company_id):
if record.website_id.company_id != record.company_id:
raise ValidationError(_("""Only the company's websites are allowed.\nLeave the Company field empty or select a website from that company."""))

View file

@ -0,0 +1,23 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ProductPricelistItem(models.Model):
_inherit = 'product.pricelist.item'
def _show_discount_on_shop(self):
"""On ecommerce, formula rules are also expected to show discounts.
Only for /shop, /product, and configurators, not on the cart or the checkout.
"""
if not self:
return False
self.ensure_one()
return self.compute_price == 'percentage' or (
self.compute_price == 'formula'
and self.price_discount
and self.base in ('list_price', 'pricelist')
)

View file

@ -1,24 +1,54 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.http import request
from odoo.tools import float_round
class Product(models.Model):
_inherit = "product.product"
class ProductProduct(models.Model):
_inherit = 'product.product'
_mail_post_access = 'read'
variant_ribbon_id = fields.Many2one(string="Variant Ribbon", comodel_name='product.ribbon')
website_id = fields.Many2one(related='product_tmpl_id.website_id', readonly=False)
product_variant_image_ids = fields.One2many('product.image', 'product_variant_id', string="Extra Variant Images")
product_variant_image_ids = fields.One2many(
string="Extra Variant Images",
comodel_name='product.image',
inverse_name='product_variant_id',
)
website_url = fields.Char('Website URL', compute='_compute_product_website_url', help='The full URL to access the document through the website.')
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.",
required=True,
default=1,
)
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',
)
base_unit_price = fields.Monetary(
string="Price Per Unit",
compute='_compute_base_unit_price',
)
base_unit_name = fields.Char(
help="Displays the custom unit for the products if defined or the selected unit of measure"
" otherwise.",
compute='_compute_base_unit_name',
)
base_unit_count = fields.Float('Base Unit Count', required=True, default=1, help="Display base unit price on your eCommerce pages. Set to 0 to hide it for this product.")
base_unit_id = fields.Many2one('website.base.unit', string='Custom Unit of Measure', help="Define a custom unit to display in the price per unit of measure field.")
base_unit_price = fields.Monetary("Price Per Unit", currency_field="currency_id", 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.')
website_url = fields.Char(
string="Website URL",
help="The full URL to access the document through the website.",
compute='_compute_product_website_url',
)
#=== COMPUTE METHODS ===#
def _get_base_unit_price(self, price):
self.ensure_one()
@ -37,17 +67,27 @@ class Product(models.Model):
for product in self:
product.base_unit_name = product.base_unit_id.name or product.uom_name
@api.constrains('base_unit_count')
def _check_base_unit_count(self):
if any(product.base_unit_count < 0 for product in self):
raise ValidationError(_('The value of Base Unit Count must be greater than 0. Use 0 to hide the price per unit on this product.'))
@api.depends_context('lang')
@api.depends('product_tmpl_id.website_url', 'product_template_attribute_value_ids')
def _compute_product_website_url(self):
for product in self:
attributes = ','.join(str(x) for x in product.product_template_attribute_value_ids.ids)
product.website_url = "%s#attr=%s" % (product.product_tmpl_id.website_url, attributes)
url = product.product_tmpl_id.website_url
if pavs := product.product_template_attribute_value_ids.product_attribute_value_id:
pav_ids = [str(pav.id) for pav in pavs]
url = f'{url}?attribute_values={",".join(pav_ids)}'
product.website_url = url
#=== CONSTRAINT METHODS ===#
@api.constrains('base_unit_count')
def _check_base_unit_count(self):
if any(product.base_unit_count < 0 for product in self):
raise ValidationError(_(
"The value of Base Unit Count must be greater than 0."
" Use 0 to hide the price per unit on this product."
))
#=== BUSINESS METHODS ===#
def _prepare_variant_values(self, combination):
variant_dict = super()._prepare_variant_values(combination)
@ -79,26 +119,115 @@ class Product(models.Model):
template_images = list(self.product_tmpl_id.product_template_image_ids)
return [self] + variant_images + template_images
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()
return self.sale_ok and (not website.prevent_zero_price_sale or self._get_contextual_price())
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()
def _is_add_to_cart_allowed(self):
self.ensure_one()
return self.user_has_groups('base.group_system') or (self.active and self.sale_ok and self.website_published)
if self.env.user.has_group('base.group_system'):
return True
if not self.active or not self.website_published:
return False
if not self.filtered_domain(self.env['website']._product_domain()):
return False
if request.website.prevent_zero_price_sale and not self._get_contextual_price():
return False
return request.website.has_ecommerce_access()
def _get_contextual_price_tax_selection(self):
@api.onchange('public_categ_ids')
def _onchange_public_categ_ids(self):
if self.public_categ_ids:
self.website_published = True
else:
self.website_published = False
def _to_markup_data(self, website):
""" Generate JSON-LD markup data for the current product.
:param website website: The current website.
:return: The JSON-LD markup data.
:rtype: dict
"""
self.ensure_one()
fpos_id = self.env['website'].sudo()._get_current_fiscal_position_id(self.env.user.partner_id)
fiscal_position_sudo = self.env['account.fiscal.position'].sudo().browse(fpos_id)
product_taxes = self.sudo().taxes_id.filtered(lambda x: x.company_id == self.env.company)
return self.env['product.template']._price_with_tax_computed(
self._get_contextual_price(),
product_taxes,
fiscal_position_sudo.map_tax(product_taxes),
self.env.company.id,
self.env['product.template']._get_contextual_pricelist(),
self,
self.env.user.partner_id,
product_price = request.pricelist._get_product_price(
self, quantity=1, currency=website.currency_id
)
# Use sudo to access cross-company taxes.
product_taxes_sudo = self.sudo().taxes_id._filter_taxes_by_company(self.env.company)
taxes = request.fiscal_position.map_tax(product_taxes_sudo)
price = self.product_tmpl_id._apply_taxes_to_price(
product_price, website.currency_id, product_taxes_sudo, taxes, self, website=website
)
base_url = website.get_base_url()
markup_data = {
'@context': 'https://schema.org',
'@type': 'Product',
'name': self.with_context(display_default_code=False).display_name,
'url': f'{base_url}{self.website_url}',
'image': f'{base_url}{website.image_url(self, "image_1920")}',
'offers': {
'@type': 'Offer',
'price': price,
'priceCurrency': website.currency_id.name,
},
}
if self.website_meta_description or self.description_sale:
markup_data['description'] = self.website_meta_description or self.description_sale
if website.is_view_active('website_sale.product_comment') and self.rating_count:
markup_data['aggregateRating'] = {
'@type': 'AggregateRating',
# sudo: product.product - visitor can access product average rating
'ratingValue': self.sudo().rating_avg,
'reviewCount': self.rating_count,
}
return markup_data
def _get_image_1920_url(self):
""" Returns the local url of the product main image.
Note: self.ensure_one()
:rtype: str
"""
self.ensure_one()
return self.env['website'].image_url(self, 'image_1920')
def _get_extra_image_1920_urls(self):
""" Returns the local url of the product additional images, no videos. This includes the
variant specific images first and then the template images.
Note: self.ensure_one()
:rtype: list[str]
"""
self.ensure_one()
return [
self.env['website'].image_url(extra_image, 'image_1920')
for extra_image in self.product_variant_image_ids + self.product_template_image_ids
if extra_image.image_128 # only images, no video urls
]
def write(self, vals):
if 'active' in vals and not vals['active']:
# unlink draft lines containing the archived product
self.env['sale.order.line'].sudo().search([
('state', '=', 'draft'),
('product_id', 'in', self.ids),
('order_id', 'any', [('website_id', '!=', False)]),
]).unlink()
return super().write(vals)

View file

@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo import api, fields, models
from odoo.fields import Domain
from odoo.tools.translate import html_translate
class ProductPublicCategory(models.Model):
_name = "product.public.category"
_name = 'product.public.category'
_inherit = [
'website.seo.metadata',
'website.multi.mixin',
@ -15,34 +15,84 @@ class ProductPublicCategory(models.Model):
]
_description = "Website Product Category"
_parent_store = True
_order = "sequence, name, id"
_order = 'sequence, name, id'
def _default_sequence(self):
cat = self.search([], limit=1, order="sequence DESC")
cat = self.search([], limit=1, order='sequence DESC')
if cat:
return cat.sequence + 5
return 10000
name = fields.Char(required=True, translate=True)
parent_id = fields.Many2one('product.public.category', string='Parent Category', index=True, ondelete="cascade")
parent_path = fields.Char(index=True, unaccent=False)
child_id = fields.One2many('product.public.category', 'parent_id', string='Children Categories')
parents_and_self = fields.Many2many('product.public.category', compute='_compute_parents_and_self')
sequence = fields.Integer(help="Gives the sequence order when displaying a list of product categories.", index=True, default=_default_sequence)
website_description = fields.Html('Category Description', sanitize_overridable=True, sanitize_attributes=False, translate=html_translate, sanitize_form=False)
product_tmpl_ids = fields.Many2many('product.template', relation='product_public_category_product_template_rel')
cover_image = fields.Image(
string="Cover Image", help="Displayed only in the Category List Snippet.",
)
sequence = fields.Integer(default=_default_sequence, index=True)
@api.constrains('parent_id')
def check_parent_id(self):
if not self._check_recursion():
raise ValueError(_('Error ! You cannot create recursive categories.'))
parent_id = fields.Many2one(
string="Parent",
comodel_name='product.public.category',
ondelete='cascade',
index=True,
)
child_id = fields.One2many(
string="Children Categories",
comodel_name='product.public.category',
inverse_name='parent_id',
)
parent_path = fields.Char(index=True)
parents_and_self = fields.Many2many(
comodel_name='product.public.category',
compute='_compute_parents_and_self',
)
def name_get(self):
res = []
for category in self:
res.append((category.id, " / ".join(category.parents_and_self.mapped('name'))))
return res
product_tmpl_ids = fields.Many2many(
comodel_name='product.template',
relation='product_public_category_product_template_rel',
)
has_published_products = fields.Boolean(
compute='_compute_has_published_products',
search='_search_has_published_products',
compute_sudo=True,
recursive=True,
)
website_description = fields.Html(
string="Description",
sanitize_attributes=False,
sanitize_form=False,
sanitize_overridable=True,
translate=html_translate,
)
website_footer = fields.Html(
string="Category Footer",
sanitize_attributes=False,
sanitize_form=False,
translate=html_translate,
)
show_category_title = fields.Boolean(
string="Show Category Title",
default=False,
help="Display the category title on the shop page. Corresponds to the 'Show Title' editor option."
)
show_category_description = fields.Boolean(
string="Show Category Description",
default=True,
help="Display the category description on the shop page. Corresponds to the 'Show Description' editor option."
)
align_category_content = fields.Boolean(
string="Align Category Content",
default=False,
help="Align the category content on the shop page. Corresponds to the 'Center Content' editor option."
)
# === COMPUTE METHODS === #
@api.depends('parent_path')
def _compute_parents_and_self(self):
for category in self:
if category.parent_path:
@ -50,6 +100,51 @@ class ProductPublicCategory(models.Model):
else:
category.parents_and_self = category
@api.depends('parents_and_self')
def _compute_display_name(self):
for category in self:
category.display_name = " / ".join(category.parents_and_self.mapped(
lambda cat: cat.name or self.env._("New")
))
@api.depends('product_tmpl_ids.is_published', 'child_id.has_published_products')
def _compute_has_published_products(self):
grouped_product_templates = self.env['product.template']._read_group(
domain=[('public_categ_ids', 'in', self.ids), ('is_published', '=', True), ('active', '=', True)],
groupby=['public_categ_ids']
)
published_category_ids = {group[0].id for group in grouped_product_templates}
for category in self:
has_published = category.id in published_category_ids
category.has_published_products = (
has_published or any(c.has_published_products for c in category.child_id)
)
# === CONSTRAINT METHODS === #
@api.constrains('parent_id')
def check_parent_id(self):
if self._has_cycle():
raise ValueError(self.env._("Error! You cannot create recursive categories."))
# === SEARCH METHODS === #
@api.model
def _search_has_published_products(self, operator, value):
if operator != 'in':
return NotImplemented
published_categ_ids = self._search(
[('product_tmpl_ids', 'any', [('is_published', '=', True), ('active', '=', True)])]
).get_result_ids()
# Note that if the `value` is False, the ORM will invert the domain below
return [
'|',
('id', 'in', published_categ_ids),
('id', 'parent_of', published_categ_ids),
]
# === BUSINESS METHODS === #
@api.model
def _search_get_detail(self, website, order, options):
with_description = options['displayDescription']
@ -78,3 +173,35 @@ class ProductPublicCategory(models.Model):
for data in results_data:
data['url'] = '/shop/category/%s' % data['id']
return results_data
@api.model
def get_available_snippet_categories(self, website_id):
"""Return parent categories available for selection in the dynamic category snippet.
:param int website_id: ID of the current website
:return: Available parent categories
:rtype: list[dict]
"""
child_count_by_parent = self._read_group(
domain=self._get_available_category_domain(website_id),
aggregates=['id:count'],
groupby=['parent_id'],
)
return [{
'id': parent_category.id,
'name': f'{parent_category.name} ({child_count})',
} for parent_category, child_count in child_count_by_parent if parent_category]
@api.model
def _get_available_category_domain(self, website_id):
"""Build a search domain for product categories to be used in dynamic snippets.
:param int website_id: ID of the current website
:return: A domain to filter product categories for the given website
:rtype: Domain
"""
domain = Domain('website_id', 'in', [False, website_id])
# Public and portal users should only see categories with published products.
if not self.env.user.has_group('website.group_website_designer'):
domain &= Domain('has_published_products', '=', True)
return domain

View file

@ -1,18 +1,129 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, tools
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class ProductRibbon(models.Model):
_name = "product.ribbon"
_description = 'Product ribbon'
_name = 'product.ribbon'
_description = "Product ribbon"
_order = 'sequence ASC, id'
def name_get(self):
return [(ribbon.id, '%s (#%d)' % (tools.html2plaintext(ribbon.html), ribbon.id)) for ribbon in self]
name = fields.Char(string="Ribbon Name", required=True, translate=True, size=20)
sequence = fields.Integer(default=10)
bg_color = fields.Char(string="Background Color", required=True, default='#000000')
text_color = fields.Char(string="Text Color", required=True, default='#FFFFFF')
position = fields.Selection(
string='Position',
selection=[('left', "Left"), ('right', "Right")],
required=True,
default='left',
)
style = fields.Selection(
string="Style",
selection=[('ribbon', "Ribbon"), ('tag', "Badge")],
required=True,
default='ribbon',
help=(
"Defines the display style:\n"
"- Ribbon: Shows a ribbon banner on the product image.\n"
"- Badge: Shows a small badge label on the product image."
),
)
assign = fields.Selection(
string="Assign",
selection=[
('manual', "Manually"),
('sale', "On Sale"),
('new', "When New"),
],
required=True,
default='manual',
help=(
"Defines how this ribbon is assigned to products:\n"
"- Manually: You assign the ribbon manually to products.\n"
"- Sale: Applied when the product is visibly on sale.\n"
"- New: Applied based on the New period you will define.\n"
),
)
new_period = fields.Integer(default=30)
html = fields.Html(string='Ribbon html', required=True, translate=True, sanitize=False)
bg_color = fields.Char(string='Ribbon background color', required=False)
text_color = fields.Char(string='Ribbon text color', required=False)
html_class = fields.Char(string='Ribbon class', required=True, default='')
product_tag_ids = fields.One2many('product.tag', 'ribbon_id', string='Product Tags')
@api.constrains('assign')
def _check_assign(self):
"""
Ensure only one ribbon exists per automatic assign type.
This prevents duplicates, since automatic assignment logic always uses the first ribbon
with a given assign value.
"""
for ribbon in self:
if ribbon.assign != 'manual':
existing_ribbons = self.search([
('id', '!=', ribbon.id),
('assign', '=', ribbon.assign)
], limit=1)
if existing_ribbons:
raise ValidationError(
_(
"Only one ribbon with the assign %s is allowed.",
dict(self._fields['assign'].selection).get(ribbon.assign)
)
)
def _get_css_classes(self):
"""
Return the CSS classes for this ribbon based on style and position.
rtype: str
"""
css_classes = ""
match self.style:
case 'ribbon':
css_classes += "o_wsale_ribbon"
case 'tag':
css_classes += "o_wsale_badge"
match self.position:
case 'left':
css_classes += " o_left"
case 'right':
css_classes += " o_right"
return css_classes
def _is_applicable_for(self, product, price_data):
"""Return whether the product matches the criteria of the ribbon automatic assignment.
:param product.product product: the displayed product
:param dict price_data: price information for the given product
(sales price for shop page, combination information for product page)
:return: Whether the ribbon matches the given product and price.
:rtype: bool
"""
self.ensure_one()
# Check if a discount is applied to the product using a pricelist, comparison price, or
# others.
if ( # noqa: SIM103
self.assign == 'sale'
and price_data
and (
# for /shop page
(
'base_price' in price_data
and (price_data['base_price'] > price_data['price_reduce'])
)
# for /product page
or (
'compare_list_price' in price_data
and price_data['compare_list_price'] > price_data['price']
)
or price_data.get('has_discounted_price')
)
):
return True
# Check if the product is published within the ribbon's new period.
if ( # noqa: SIM103
self.assign == 'new'
and self.new_period >= (fields.Datetime.today() - product.publish_date).days
):
return True
return False

View file

@ -1,10 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo import models
class ProductTag(models.Model):
_name = 'product.tag'
_inherit = ['website.multi.mixin', 'product.tag']
ribbon_id = fields.Many2one('product.ribbon', string='Ribbon')

View file

@ -0,0 +1,44 @@
# 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
and ptal.attribute_id.display_type != 'multi'
and not ptal.value_ids.is_custom
))
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
def _prepare_single_value_including_multi_type_for_display(self):
"""On the product page group together the attribute lines that concern
the same attribute and that have only one value each.
Unlike `_prepare_single_value_for_display`, this method also includes
the attribute lines with a display type 'multi'
"""
single_value_lines = self.filtered(
lambda ptal: len(ptal.value_ids) == 1 and not ptal.value_ids.is_custom
)
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

View file

@ -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

View file

@ -1,17 +1,20 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo import models
class ResCompany(models.Model):
_inherit = 'res.company'
website_sale_onboarding_payment_provider_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")], string="State of the website sale onboarding payment provider step", default='not_done')
def _get_default_pricelist_vals(self):
""" Override of product. Called at company creation or activation of the pricelist setting.
@api.model
def action_open_website_sale_onboarding_payment_provider(self):
""" Called by onboarding panel above the quotation list."""
self.env.company.payment_onboarding_payment_method = 'stripe'
menu_id = self.env.ref('website.menu_website_dashboard').id
return self._run_payment_onboarding_step(menu_id)
We don't want the default website from the current company to be applied on every company
Note: self.ensure_one()
:rtype: dict
"""
values = super()._get_default_pricelist_vals()
values['website_id'] = False
return values

View file

@ -1,171 +1,182 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, fields, _
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
salesperson_id = fields.Many2one('res.users', related='website_id.salesperson_id', string='Salesperson', readonly=False, domain="[('share', '=', False)]")
salesteam_id = fields.Many2one('crm.team', related='website_id.salesteam_id', string='Sales Team', readonly=False)
module_website_sale_delivery = fields.Boolean("eCommerce Shipping Costs")
# field used to have a nice radio in form view, resuming the 2 fields above
sale_delivery_settings = fields.Selection([
('none', 'No shipping management on website'),
('internal', "Delivery methods are only used internally: the customer doesn't pay for shipping costs"),
('website', "Delivery methods are selectable on the website: the customer pays for shipping costs"),
], string="Shipping Management")
group_delivery_invoice_address = fields.Boolean(string="Shipping Address", implied_group='account.group_delivery_invoice_address', group='base.group_portal,base.group_user,base.group_public')
group_show_uom_price = fields.Boolean(default=False, string="Base Unit Price", implied_group="website_sale.group_show_uom_price", group='base.group_portal,base.group_user,base.group_public')
# Groups
group_show_uom_price = fields.Boolean(
string="Base Unit Price",
default=False,
implied_group="website_sale.group_show_uom_price",
group='base.group_user',
)
group_product_price_comparison = fields.Boolean(
string="Comparison Price",
implied_group="website_sale.group_product_price_comparison",
group='base.group_portal,base.group_user,base.group_public')
group='base.group_user',
help="Add a strikethrough price to your /shop and product pages for comparison purposes."
"It will not be displayed if pricelists apply."
)
group_gmc_feed = fields.Boolean(
string="Google Merchant Center",
implied_group='website_sale.group_product_feed',
group='base.group_user',
related='website_id.enabled_gmc_src',
readonly=False,
)
module_website_sale_digital = fields.Boolean("Digital Content")
module_website_sale_wishlist = fields.Boolean("Wishlists")
module_website_sale_comparison = fields.Boolean("Product Comparison Tool")
module_website_sale_autocomplete = fields.Boolean('Address Autocomplete')
# Modules
module_website_sale_autocomplete = fields.Boolean("Address Autocomplete")
module_website_sale_collect = fields.Boolean("Click & Collect")
module_account = fields.Boolean("Invoicing")
module_website_sale_picking = fields.Boolean('On Site Payments & Picking')
cart_recovery_mail_template = fields.Many2one('mail.template', string='Cart Recovery Email', domain="[('model', '=', 'sale.order')]",
related='website_id.cart_recovery_mail_template_id', readonly=False)
cart_abandoned_delay = fields.Float(string="Send After", related='website_id.cart_abandoned_delay', readonly=False)
send_abandoned_cart_email = fields.Boolean('Abandoned Email', related='website_id.send_abandoned_cart_email', readonly=False)
# Website-dependent settings
add_to_cart_action = fields.Selection(related='website_id.add_to_cart_action', readonly=False)
terms_url = fields.Char(compute='_compute_terms_url', string="URL", help="A preview will be available at this URL.")
module_delivery = fields.Boolean(
compute='_compute_module_delivery', store=True, readonly=False)
module_delivery_mondialrelay = fields.Boolean("Mondial Relay Connector")
module_website_sale_delivery = fields.Boolean(
compute='_compute_module_delivery', store=True, readonly=False)
group_product_pricelist = fields.Boolean(
compute='_compute_group_product_pricelist', store=True, readonly=False)
enabled_extra_checkout_step = fields.Boolean(string="Extra Step During Checkout", compute='_compute_checkout_process_steps', readonly=False, store=True)
enabled_buy_now_button = fields.Boolean(string="Buy Now", compute='_compute_checkout_process_steps', readonly=False, store=True)
cart_recovery_mail_template = fields.Many2one(
related='website_id.cart_recovery_mail_template_id',
readonly=False,
)
cart_abandoned_delay = fields.Float(
related='website_id.cart_abandoned_delay',
readonly=False,
)
send_abandoned_cart_email = fields.Boolean(
string="Abandoned Email",
related='website_id.send_abandoned_cart_email',
readonly=False,
)
salesperson_id = fields.Many2one(
related='website_id.salesperson_id',
readonly=False,
)
salesteam_id = fields.Many2one(related='website_id.salesteam_id', readonly=False)
website_sale_prevent_zero_price_sale = fields.Boolean(
string="Prevent Sale of Zero Priced Product",
related='website_id.prevent_zero_price_sale',
readonly=False,
)
website_sale_contact_us_button_url = fields.Char(
string="Button Url",
related='website_id.contact_us_button_url',
readonly=False,
)
show_line_subtotals_tax_selection = fields.Selection(
related='website_id.show_line_subtotals_tax_selection',
readonly=False,
)
confirmation_email_template_id = fields.Many2one(
related='website_id.confirmation_email_template_id', readonly=False
)
# Additional settings
account_on_checkout = fields.Selection(
string="Customer Accounts",
selection=[
("optional", "Optional"),
("disabled", "Disabled (buy as guest)"),
("mandatory", "Mandatory (no guest checkout)"),
("disabled", "Disabled"),
("mandatory", "Mandatory"),
],
compute="_compute_account_on_checkout",
inverse="_inverse_account_on_checkout",
readonly=False, required=True)
website_sale_prevent_zero_price_sale = fields.Boolean(string="Prevent Sale of Zero Priced Product", related='website_id.prevent_zero_price_sale', readonly=False)
website_sale_contact_us_button_url = fields.Char(string="Button URL", related='website_id.contact_us_button_url', readonly=False)
website_sale_enabled_portal_reorder_button = fields.Boolean(string="Re-order From Portal", related='website_id.enabled_portal_reorder_button', readonly=False)
readonly=False,
required=True,
)
ecommerce_access = fields.Selection(
related='website_id.ecommerce_access',
readonly=False,
)
@api.depends('website_id')
def _compute_terms_url(self):
for record in self:
record.terms_url = '%s/terms' % record.website_id.get_base_url()
@api.model
def get_values(self):
res = super(ResConfigSettings, self).get_values()
sale_delivery_settings = 'none'
if self.env['ir.module.module'].search([('name', '=', 'delivery')], limit=1).state in ('installed', 'to install', 'to upgrade'):
sale_delivery_settings = 'internal'
if self.env['ir.module.module'].search([('name', '=', 'website_sale_delivery')], limit=1).state in ('installed', 'to install', 'to upgrade'):
sale_delivery_settings = 'website'
res.update(
sale_delivery_settings=sale_delivery_settings,
)
return res
def set_values(self):
super().set_values()
if self.website_id:
website = self.with_context(website_id=self.website_id.id).website_id
extra_step_view = website.viewref('website_sale.extra_info_option')
buy_now_view = website.viewref('website_sale.product_buy_now')
if extra_step_view.active != self.enabled_extra_checkout_step:
extra_step_view.active = self.enabled_extra_checkout_step
if buy_now_view.active != self.enabled_buy_now_button:
buy_now_view.active = self.enabled_buy_now_button
@api.depends('sale_delivery_settings')
def _compute_module_delivery(self):
for wizard in self:
wizard.module_delivery = wizard.sale_delivery_settings in ['internal', 'website']
wizard.module_website_sale_delivery = wizard.sale_delivery_settings == 'website'
@api.depends('group_discount_per_so_line')
def _compute_group_product_pricelist(self):
self.filtered(lambda w: w.group_discount_per_so_line).update({
'group_product_pricelist': True,
})
# === COMPUTE METHODS === #
@api.depends('website_id.account_on_checkout')
def _compute_account_on_checkout(self):
for record in self:
record.account_on_checkout = record.website_id.account_on_checkout or 'disabled'
@api.depends('website_id')
def _compute_checkout_process_steps(self):
"""
Computing the extra info step and buy now settings when changing
the website in the res.config.settings page to show the correct value
in the checkbox.
"""
for record in self:
website = record.with_context(website_id=record.website_id.id).website_id
record.enabled_extra_checkout_step = website.is_view_active(
'website_sale.extra_info_option'
)
record.enabled_buy_now_button = website.is_view_active(
'website_sale.product_buy_now'
)
def _inverse_account_on_checkout(self):
for record in self:
if not record.website_id:
continue
record.website_id.account_on_checkout = record.account_on_checkout
# account_on_checkout implies different values for `auth_signup_uninvited`
if record.account_on_checkout in ['optional', 'mandatory']:
record.website_id.auth_signup_uninvited = 'b2c'
else:
record.website_id.auth_signup_uninvited = 'b2b'
if record.website_id.account_on_checkout != record.account_on_checkout:
if self.account_on_checkout in ['optional', 'mandatory']:
record.website_id.auth_signup_uninvited = 'b2c'
else:
record.website_id.auth_signup_uninvited = 'b2b'
record.website_id.account_on_checkout = record.account_on_checkout
def action_update_terms(self):
self.ensure_one()
return self.env["website"].get_client_action('/terms', True)
# === CRUD METHODS === #
def action_open_extra_info(self):
self.ensure_one()
# Add the "edit" parameter in the url to tell the controller
# that we want to edit even if we are not in a payment flow
return self.env["website"].get_client_action('/shop/extra_info?open_editor=true', True, self.website_id.id)
def set_values(self):
super().set_values()
if self.website_id:
website = self.with_context(website_id=self.website_id.id).website_id
def action_open_sale_mail_templates(self):
return {
'name': _('Customize Email Templates'),
'type': 'ir.actions.act_window',
'domain': [('model', '=', 'sale.order')],
'res_model': 'mail.template',
'view_id': False,
'view_mode': 'tree,form',
}
# Pre-populate the website feeds if none already exists.
if (
self.group_gmc_feed
and not self.env['product.feed'].search_count(
[('website_id', '=', website.id)], limit=1
)
):
website._populate_product_feeds()
# Due to an earlier oversight, the GMC feature flag was implemented as website-specific,
# even though a group-based feature flag is global. This has been corrected in future
# versions, but fixing it here would require a model change, which cannot be backported.
# This line serves as a workaround to ensure that all websites share the same setting,
# providing consistent behavior across versions.
self.env['website'].sudo().search_fetch([], []).enabled_gmc_src = self.group_gmc_feed
# === ACTION METHODS === #
def action_view_delivery_provider_modules(self):
return self.env['delivery.carrier'].install_more_provider()
@api.readonly
def action_open_abandoned_cart_mail_template(self):
return {
'name': _('Customize Email Templates'),
'name': self.env._("Customize Email Templates"),
'type': 'ir.actions.act_window',
'res_model': 'mail.template',
'view_id': False,
'view_mode': 'form',
'res_id': self.env['ir.model.data']._xmlid_to_res_id("website_sale.mail_template_sale_cart_recovery"),
}
def action_open_extra_info(self):
self.ensure_one()
# Add the "edit" parameter in the url to tell the controller
# that we want to edit even if we are not in a payment flow
return self.env["website"].get_client_action(
'/shop/extra_info?open_editor=true', mode_edit=True, website_id=self.website_id.id)
@api.readonly
def action_open_sale_mail_templates(self):
return {
'name': self.env._("Customize Email Templates"),
'type': 'ir.actions.act_window',
'domain': [('model', '=', 'sale.order')],
'res_model': 'mail.template',
'view_id': False,
'view_mode': 'list,form',
}
@api.readonly
def action_open_product_feeds(self):
"""Open the list view to manage the feed specific to the current website."""
self.ensure_one()
return {
'name': self.env._("Product Feeds"),
'type': 'ir.actions.act_window',
'res_model': 'product.feed',
'views': [(False, 'list')],
'target': 'new',
'context': {
'default_website_id': self.website_id.id,
'hide_website_column': True,
},
'domain': [('website_id', '=', self.website_id.id)],
}

View file

@ -1,14 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ResCountry(models.Model):
_inherit = 'res.country'
def get_website_sale_countries(self, mode='billing'):
return self.sudo().search([])
def get_website_sale_states(self, mode='billing'):
return self.sudo().state_ids

View file

@ -1,30 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.addons.website.models import ir_http
from odoo import _, api, models
from odoo.fields import Domain
from odoo.http import request
class ResPartner(models.Model):
_inherit = 'res.partner'
last_website_so_id = fields.Many2one('sale.order', compute='_compute_last_website_so_id', string='Last Online Sales Order')
def _compute_last_website_so_id(self):
SaleOrder = self.env['sale.order']
for partner in self:
is_public = partner.is_public
website = ir_http.get_request_website()
if website and not is_public:
partner.last_website_so_id = SaleOrder.search([
('partner_id', '=', partner.id),
('pricelist_id', '=', partner.property_product_pricelist.id),
('website_id', '=', website.id),
('state', '=', 'draft'),
], order='write_date desc', limit=1)
else:
partner.last_website_so_id = SaleOrder # Not in a website context or public User
@api.onchange('property_product_pricelist')
def _onchange_property_product_pricelist(self):
open_order = self.env['sale.order'].sudo().search([
@ -45,22 +28,47 @@ class ResPartner(models.Model):
),
}}
def _get_current_partner(self, *, order_sudo=False, **kwargs):
""" Override `portal` to get current partner from order_sudo if user is not signed up. """
if order_sudo:
return (
(not order_sudo._is_anonymous_cart() and order_sudo.partner_id)
or self.env['res.partner'] # Avoid returning public user's partner
)
return super()._get_current_partner(order_sudo=order_sudo, **kwargs)
def _get_frontend_writable_fields(self):
""" Override `portal` to make website whitelist fields writable in portal address. """
frontend_writable_fields = super()._get_frontend_writable_fields()
frontend_writable_fields.update(
self.env['ir.model']._get('res.partner')._get_form_writable_fields().keys()
)
return frontend_writable_fields
def _get_order_fiscal_position_recompute_domain(self):
"""Return a domain of sale orders for which we should recompute fiscal position after address update."""
return Domain([
('state', '=', 'draft'),
('website_id', '!=', False),
'|', ('partner_id', 'in', self.ids),
('partner_shipping_id', 'in', self.ids),
])
def write(self, vals):
res = super().write(vals)
if {'country_id', 'vat', 'zip'} & vals.keys():
if {'country_id', 'vat', 'zip'} & vals.keys() and self:
# Recompute fiscal position for open website orders
orders_sudo = self.env['sale.order'].sudo().search([
('state', '=', 'draft'),
('website_id', '!=', False),
'|', ('partner_id', 'in', self.ids), ('partner_shipping_id', 'in', self.ids),
])
if orders_sudo:
fpos_by_order = {so.id: so.fiscal_position_id.id for so in orders_sudo}
order_fpos_recompute_domain = self._get_order_fiscal_position_recompute_domain()
if orders_sudo := self.env['sale.order'].sudo().search(order_fpos_recompute_domain):
orders_by_fpos = orders_sudo.grouped('fiscal_position_id')
self.env.add_to_compute(orders_sudo._fields['fiscal_position_id'], orders_sudo)
fpos_changed = orders_sudo.filtered(
lambda so: so.fiscal_position_id.id != fpos_by_order[so.id],
)
if fpos_changed:
if fpos_changed := orders_sudo.filtered(
lambda so: so not in orders_by_fpos.get(so.fiscal_position_id, []),
):
fpos_changed._recompute_taxes()
fpos_changed._recompute_prices()
# other modules may extend the orders to recompute for
# non-draft orders (for ex. sale_subscription), we need
# to ensure to only recompute prices for draft orders
fpos_changed.filtered(lambda order: order.state == 'draft')._recompute_prices()
return res

View file

@ -1,29 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, fields, _
from odoo import api, fields, models
from odoo.exceptions import UserError
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
_inherit = 'sale.order.line'
linked_line_id = fields.Many2one('sale.order.line', string='Linked Order Line', domain="[('order_id', '=', order_id)]", ondelete='cascade', copy=False, index=True)
option_line_ids = fields.One2many('sale.order.line', 'linked_line_id', string='Options Linked')
name_short = fields.Char(compute="_compute_name_short")
shop_warning = fields.Char('Warning')
name_short = fields.Char(compute='_compute_name_short')
shop_warning = fields.Char(string="Warning")
#=== COMPUTE METHODS ===#
@api.depends('linked_line_id', 'option_line_ids')
def _compute_name(self):
"""Override to add the compute dependency.
The custom name logic can be found below in _get_sale_order_line_multiline_description_sale.
"""
super()._compute_name()
@api.depends('product_id.display_name')
def _compute_name_short(self):
""" Compute a short name for this sale order line, to be used on the website where we don't have much space.
@ -34,19 +22,17 @@ class SaleOrderLine(models.Model):
#=== BUSINESS METHODS ===#
def _get_sale_order_line_multiline_description_sale(self):
description = super()._get_sale_order_line_multiline_description_sale()
if self.linked_line_id:
description += "\n" + _("Option for: %s", self.linked_line_id.product_id.display_name)
if self.option_line_ids:
description += "\n" + '\n'.join([
_("Option: %s", option_line.product_id.display_name)
for option_line in self.option_line_ids
])
return description
def get_description_following_lines(self):
return self.name.splitlines()[1:]
return reversed(self.name.splitlines()[1:])
def _get_combination_name(self):
return self.product_id.product_template_attribute_value_ids._get_combination_name()
def _get_line_header(self):
if not self.product_template_attribute_value_ids:
return self.name_short
# not display_name because we don't want the combination name or the code.
return self.product_id.name
def _get_order_date(self):
self.ensure_one()
@ -63,10 +49,84 @@ class SaleOrderLine(models.Model):
self.shop_warning = ''
return warn
def _get_displayed_unit_price(self):
show_tax = self.order_id.website_id.show_line_subtotals_tax_selection
tax_display = 'total_excluded' if show_tax == 'tax_excluded' else 'total_included'
is_combo = self.product_type == 'combo'
unit_price = self._get_display_price_ignore_combo() if is_combo else self.price_unit
return self.tax_ids.compute_all(
unit_price, self.currency_id, 1, self.product_id, self.order_partner_id,
)[tax_display]
def _get_selected_combo_items(self):
if self.product_id.type == 'combo':
return [{
'id': linked_line.combo_item_id.id,
'no_variant_ptav_ids': linked_line.product_no_variant_attribute_value_ids.ids,
'custom_ptavs': [{
'id': pcav.custom_product_template_attribute_value_id.id,
'value': pcav.custom_value,
} for pcav in linked_line.product_custom_attribute_value_ids]
} for linked_line in self.linked_line_ids]
return None
def _get_displayed_quantity(self):
rounded_uom_qty = round(self.product_uom_qty,
self.env['decimal.precision'].precision_get('Product Unit'))
return int(rounded_uom_qty) == rounded_uom_qty and int(rounded_uom_qty) or rounded_uom_qty
def _show_in_cart(self):
self.ensure_one()
return not bool(self.display_type)
# Exclude delivery & section/note lines from showing up in the cart
return not self.is_delivery and not bool(self.display_type) and not bool(self.combo_item_id)
def _is_reorder_allowed(self):
self.ensure_one()
return self.product_id._is_add_to_cart_allowed()
return (
bool(self.product_id)
and self.product_id._is_add_to_cart_allowed()
and self._show_in_cart()
)
def _get_cart_display_price(self):
self.ensure_one()
price_type = (
'price_subtotal'
if self.order_id.website_id.show_line_subtotals_tax_selection == 'tax_excluded'
else 'price_total'
)
return sum(self._get_lines_with_price().mapped(price_type))
def _check_validity(self):
if (
not self.combo_item_id
and sum(self._get_lines_with_price().mapped('price_unit')) == 0
and self.order_id.website_id.prevent_zero_price_sale
and self.product_template_id.service_tracking not in self.env['product.template']._get_product_types_allow_zero_price()
):
raise UserError(self.env._(
"The given product does not have a price therefore it cannot be added to cart.",
))
def _should_show_strikethrough_price(self):
""" Compute whether the strikethrough price should be shown.
The strikethrough price should be shown if there is a discount on a sellable line for
which a price unit is non-zero.
:return: Whether the strikethrough price should be shown.
:rtype: bool
"""
return self.discount and self._is_sellable() and self._get_displayed_unit_price()
def _is_sellable(self):
"""Check if a line is sellable or not, i.e the link is clickable in the cart or not.
A line is sellable if the product is published and not a delivery line.
:return: Whether the line is sellable or not.
:rtype: bool
"""
return self.product_id.is_published and not self.is_delivery

View file

@ -0,0 +1,28 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class ThemeUtils(models.AbstractModel):
_inherit = 'theme.utils'
category_style_templates = [
'website_sale.filmstrip_categories_bordered',
'website_sale.filmstrip_categories_tabs',
'website_sale.filmstrip_categories_pills',
'website_sale.filmstrip_categories_images',
'website_sale.filmstrip_categories_grid',
'website_sale.filmstrip_categories_large_images',
]
@api.model
def enable_view(self, xml_id):
"""Override of `theme.utils` to disable all category style templates when enabling one."""
if xml_id in self.category_style_templates:
for template in self.category_style_templates:
self.disable_view(template)
super().enable_view(xml_id)
@property
def _footer_templates(self):
return ['website_sale.template_footer_website_sale'] + super()._footer_templates

View file

@ -1,10 +1,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class WebsiteBaseUnit(models.Model):
_name = "website.base.unit"
_name = 'website.base.unit'
_description = "Unit of Measure for price per unit on eCommerce products."
_order = "name"
_order = 'name'
name = fields.Char(help="Define a custom unit to display in the price per unit of measure field.",
required=True, translate=True)
name = fields.Char(
help="Define a custom unit to display in the price per unit of measure field.",
required=True,
translate=True,
)

View file

@ -0,0 +1,33 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo.fields import Domain
class WebsiteCheckoutStep(models.Model):
_name = 'website.checkout.step'
_description = 'Website Checkout Step'
_inherit = ['website.published.multi.mixin']
name = fields.Char(required=True, translate=True)
sequence = fields.Integer()
step_href = fields.Char(string="Href", required=True)
main_button_label = fields.Char(translate=True)
back_button_label = fields.Char(translate=True)
website_id = fields.Many2one('website', ondelete='cascade')
def _get_next_checkout_step(self, allowed_steps_domain):
""" Get the next step in the checkout flow based on the sequence."""
next_step_domain = Domain.AND(
[allowed_steps_domain, [('sequence', '>', self.sequence)]]
)
return self.search(next_step_domain, order='sequence', limit=1)
def _get_previous_checkout_step(self, allowed_steps_domain):
""" Get the previous step in the checkout flow based on the sequence."""
previous_step_domain = Domain.AND(
[allowed_steps_domain, [('sequence', '<', self.sequence)]]
)
return self.search(previous_step_domain, order='sequence DESC', limit=1)

View file

@ -0,0 +1,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class WebsiteMenu(models.Model):
_inherit = 'website.menu'
def _compute_visible(self):
""" Hide '/shop' menus to the public user if only logged-in users can access it. """
shop_menus = self.filtered(lambda m: m.url[:5] == '/shop')
for menu in shop_menus:
menu.is_visible = menu.website_id.has_ecommerce_access()
return super(WebsiteMenu, self - shop_menus)._compute_visible()

View file

@ -0,0 +1,41 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo import api, models
class WebsitePage(models.Model):
_inherit = 'website.page'
@api.model
def _allow_cache_insertion(self, layout):
return ' data-order-id=' not in layout and super()._allow_cache_insertion(layout)
@api.model
def _post_process_response_from_cache(self, request, response):
super()._post_process_response_from_cache(request, response)
order_id = request.session.get('sale_order_id', '')
quantity = request.session.get('website_sale_cart_quantity', 0)
if not order_id or not quantity:
return
# update generated html from "webiste_sale.header_cart_link" used on all page
my_cart_quantity_re = re.compile(r"""
<sup\s
class="(?P<classname>my_cart_quantity[^"]*)"
(?P<attributes>[^>]*?)
>
(?P<quantity>[^<]*)
</sup>
""", re.VERBOSE)
html = response.response[0]
cache_quantity = re.search(my_cart_quantity_re, html)
classname = cache_quantity.group('classname').replace('d-none', '') + ('' if quantity else 'd-none')
attributes = cache_quantity.group('attributes') + (f' data-order-id="{order_id}"' if quantity else '')
html_quantity = f'''<sup class="{classname}"{attributes}>{quantity}</sup>'''
html = html.replace(cache_quantity.group(0), html_quantity)
response.response = [html]

View file

@ -0,0 +1,20 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class WebsiteSaleExtraField(models.Model):
_name = 'website.sale.extra.field'
_description = "E-Commerce Extra Info Shown on product page"
_order = 'sequence'
website_id = fields.Many2one(comodel_name='website', index='btree_not_null')
sequence = fields.Integer(default=10)
field_id = fields.Many2one(
comodel_name='ir.model.fields',
domain=[('model_id.model', '=', 'product.template'), ('ttype', 'in', ['char', 'binary'])],
required=True,
ondelete='cascade'
)
label = fields.Char(related='field_id.field_description')
name = fields.Char(related='field_id.name')

View file

@ -1,181 +1,324 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import Counter
from functools import partial
from odoo import models, fields, api, _
from odoo.osv import expression
from odoo import _, api, fields, models
from odoo.fields import Domain
from odoo.http import request
class WebsiteSnippetFilter(models.Model):
_inherit = 'website.snippet.filter'
product_cross_selling = fields.Boolean(string="About cross selling products", default=False,
help="True only for product filters that require a product_id because they relate to cross selling")
product_cross_selling = fields.Boolean(
string="About cross selling products",
help="True only for product filters that require a product_id because they relate to"
" cross selling",
)
def _prepare_values(self, limit=None, search_domain=None, **kwargs):
website = self.env['website'].get_current_website()
if (
(self.model_name or kwargs.get('res_model')) in ('product.product', 'product.public.category')
and not website.has_ecommerce_access()
):
return []
hide_variants = False
if search_domain and 'hide_variants' in search_domain:
hide_variants = True
search_domain.remove('hide_variants')
update_limit_cache = False
product_limit = limit or self.limit
if hide_variants and self.filter_id.model_id == 'product.product':
# When hiding variants, temporarily update cache to increase `self.limit`
# so we hopefully end up with the correct amount of product templates
update_limit_cache = partial(
self.env.cache.set,
record=self,
field=self._fields['limit'],
)
limit = product_limit ** 2 # heuristic, may still be inadequate in some cases
stored_limit = self.limit
update_limit_cache(value=limit)
res = super(
WebsiteSnippetFilter,
self.with_context(hide_variants=hide_variants, product_limit=product_limit),
)._prepare_values(limit=limit, search_domain=search_domain, **kwargs)
if update_limit_cache:
update_limit_cache(value=stored_limit)
return res
@api.model
def _get_website_currency(self):
pricelist = self.env['website'].get_current_website().get_current_pricelist()
return pricelist.currency_id
website = self.env['website'].get_current_website()
return website.currency_id
def _get_hardcoded_sample(self, model):
samples = super()._get_hardcoded_sample(model)
def merge_samples_with_data(data_):
return [
{**samples[i % len(samples)], **data_[i % len(data_)]}
for i in range(max(len(samples), len(data_)))
]
if model._name == 'product.product':
data = [{
'image_512': b'/product/static/img/product_chair.jpg',
'display_name': _('Chair'),
'description_sale': _('Sit comfortably'),
'display_name': _("Chair"),
'description_sale': _("Sit comfortably"),
}, {
'image_512': b'/product/static/img/product_lamp.png',
'display_name': _('Lamp'),
'description_sale': _('Lightbulb sold separately'),
'display_name': _("Lamp"),
'description_sale': _("Lightbulb sold separately"),
}, {
'image_512': b'/product/static/img/product_product_20-image.png',
'display_name': _('Whiteboard'),
'description_sale': _('With three feet'),
'display_name': _("Whiteboard"),
'description_sale': _("With three feet"),
}, {
'image_512': b'/product/static/img/product_product_27-image.jpg',
'display_name': _('Drawer'),
'description_sale': _('On wheels'),
'display_name': _("Drawer"),
'description_sale': _("On wheels"),
}, {
'image_512': b'/product/static/img/product_product_7-image.png',
'display_name': _('Box'),
'description_sale': _('Reinforced for heavy loads'),
'display_name': _("Box"),
'description_sale': _("Reinforced for heavy loads"),
}, {
'image_512': b'/product/static/img/product_product_9-image.jpg',
'display_name': _('Bin'),
'description_sale': _('Pedal-based opening system'),
'display_name': _("Bin"),
'description_sale': _("Pedal-based opening system"),
}]
merged = []
for index in range(0, max(len(samples), len(data))):
merged.append({**samples[index % len(samples)], **data[index % len(data)]})
# merge definitions
samples = merged
samples = merge_samples_with_data(data)
elif model._name == 'product.public.category':
data = [{
'id': 1,
'cover_image': b'/website_sale/static/src/img/categories/desks.jpg',
'name': _("Desks"),
}, {
'id': 2,
'cover_image': b'/website_sale/static/src/img/categories/furnitures.jpg',
'name': _("Furnitures"),
}, {
'id': 3,
'cover_image': b'/website_sale/static/src/img/categories/boxes.jpg',
'name': _("Boxes"),
}, {
'id': 4,
'cover_image': b'/website_sale/static/src/img/categories/drawers.jpg',
'name': _("Drawers"),
}]
samples = merge_samples_with_data(data)
return samples
def _filter_records_to_values(self, records, is_sample=False):
res_products = super()._filter_records_to_values(records, is_sample)
if self.model_name == 'product.product':
def _filter_records_to_values(self, records, **options):
hide_variants = self.env.context.get('hide_variants') and not isinstance(records, list)
if hide_variants:
product_limit = self.env.context.get('product_limit') or self.limit
records = records.product_tmpl_id[:product_limit]
res_products = super()._filter_records_to_values(records, **options)
if (self.model_name or options.get('res_model')) == 'product.product':
for res_product in res_products:
product = res_product.get('_record')
if not is_sample:
res_product.update(product._get_combination_info_variant())
if not options.get('is_sample'):
if hide_variants and not product.has_configurable_attributes:
# Still display a product.product if the template is not configurable
res_product['_record'] = product = product.product_variant_id
# TODO VFE combination_info is only called to get the price here
# factorize and avoid computing the rest
if product.is_product_variant:
res_product.update(product._get_combination_info_variant())
elif hide_variants:
res_product.update(product._get_combination_info(only_template=True))
# Re-add product_id since it is set to false and required by some tests
res_product['product_id'] = product.product_variant_id.id
else:
res_product.update(product._get_combination_info())
if records.env.context.get('add2cart_rerender'):
res_product['_add2cart_rerender'] = True
else:
res_product.update({
'is_sample': True,
})
return res_products
@api.model
def _get_products(self, mode, context):
dynamic_filter = context.get('dynamic_filter')
def _prepare_category_list_data(self, parent_id=None):
"""Return a list of categories to be displayed in the category list snippet.
If `parent_id` is provided, return it with its children, otherwise top-level categories.
:param int parent_id: ID of the parent category, if any.
:return: List of dictionaries containing category ID, name, and cover image URL.
:rtype: list[dict]
"""
CategorySudo = request.env['product.public.category'].sudo()
domain = CategorySudo._get_available_category_domain(request.website.id)
if parent_id:
parent_category = CategorySudo.browse(parent_id)
# Parent category should be first.
categories = parent_category | parent_category.child_id.filtered_domain(domain)
else: # Only top-level categories
categories = CategorySudo.search(domain & Domain('parent_id', '=', False))
base_url = CategorySudo.get_base_url()
default_img_path = request.env['product.template']._get_product_placeholder_filename()
default_img_url = f'{base_url}/{default_img_path}'
return [{
'id': cat.id,
'name': cat.name,
'unpublished': not cat.has_published_products,
'cover_image': (
f'{base_url}{request.website.image_url(cat, "cover_image")}'
if cat.cover_image else default_img_url
),
} for cat in categories]
@api.model
def _get_products(self, mode, **kwargs):
dynamic_filter = self.env.context.get('dynamic_filter')
handler = getattr(self, '_get_products_%s' % mode, self._get_products_latest_sold)
website = self.env['website'].get_current_website()
search_domain = context.get('search_domain')
limit = context.get('limit')
domain = expression.AND([
[('website_published', '=', True)],
search_domain = self.env.context.get('search_domain')
limit = self.env.context.get('limit')
hide_variants = self.env.context.get('hide_variants')
domain = Domain.AND([
[('website_published', '=', True)] if self.env.user._is_public() or self.env.user._is_portal() else [],
website.website_domain(),
[('company_id', 'in', [False, website.company_id.id])],
search_domain or [],
])
products = handler(website, limit, domain, context)
return dynamic_filter._filter_records_to_values(products, False)
products = handler(website, limit, domain, **kwargs)
return dynamic_filter.with_context(
hide_variants=hide_variants,
)._filter_records_to_values(products, is_sample=False)
def _get_products_latest_sold(self, website, limit, domain, context):
products = []
def _get_products_latest_sold(self, website, limit, domain, **kwargs):
products = self.env['product.product']
sale_orders = self.env['sale.order'].sudo().search([
('website_id', '=', website.id),
('state', 'in', ('sale', 'done')),
('company_id', '=', website.company_id.id),
('state', '=', 'sale'),
], limit=8, order='date_order DESC')
if sale_orders:
sold_products = [p.product_id.id for p in sale_orders.order_line]
products_ids = [id for id, _ in Counter(sold_products).most_common()]
if products_ids:
domain = expression.AND([
domain,
[('id', 'in', products_ids)],
])
products = self.env['product.product'].with_context(display_default_code=False).search(domain)
products = products.sorted(key=lambda p: products_ids.index(p.id))[:limit]
if self.env.context.get('hide_variants'):
sold_products = Counter(
sol.product_id.product_tmpl_id.product_variant_id
for sol in sale_orders.order_line
)
else:
sold_products = Counter(sol.product_id for sol in sale_orders.order_line)
if sold_products:
domain = Domain(domain) & Domain('id', 'in', [p.id for p, _ in sold_products.most_common(limit)])
products = self.env['product.product'].with_context(
display_default_code=False,
).search(domain, limit=limit)
products = products.sorted(key=sold_products.get, reverse=True)
return products
def _get_products_latest_viewed(self, website, limit, domain, context):
products = []
def _get_products_latest_viewed(self, website, limit, domain, **kwargs):
products = self.env['product.product']
visitor = self.env['website.visitor']._get_visitor_from_request()
if visitor:
excluded_products = website.sale_get_order().order_line.product_id.ids
tracked_products = self.env['website.track'].sudo()._read_group(
[('visitor_id', '=', visitor.id), ('product_id', '!=', False), ('product_id.website_published', '=', True), ('product_id', 'not in', excluded_products)],
['product_id', 'visit_datetime:max'], ['product_id'], limit=limit, orderby='visit_datetime DESC')
products_ids = [product['product_id'][0] for product in tracked_products]
if products_ids:
domain = expression.AND([
domain,
[('id', 'in', products_ids)],
])
products = self.env['product.product'].with_context(display_default_code=False, add2cart_rerender=True).search(domain, limit=limit)
excluded_products = request.cart.order_line.product_id.ids
tracked_products = self.env['website.track'].sudo()._read_group([
('visitor_id', '=', visitor.id),
('product_id', '!=', False),
('product_id.website_published', '=', True),
('product_id', 'not in', excluded_products),
], ['product_id'], limit=limit, order='visit_datetime:max DESC')
if self.env.context.get('hide_variants'):
product_ids = [
product.product_tmpl_id.product_variant_id.id
for [product] in tracked_products
]
else:
product_ids = [product.id for [product] in tracked_products]
if product_ids:
domain = Domain(domain) & Domain('id', 'in', product_ids)
filtered_ids = set(self.env['product.product']._search(domain, limit=limit))
# `search` will not keep the order of tracked products; however, we want to keep
# that order (latest viewed first).
products = self.env['product.product'].with_context(
display_default_code=False, add2cart_rerender=True,
).browse([product_id for product_id in product_ids if product_id in filtered_ids])
return products
def _get_products_recently_sold_with(self, website, limit, domain, context):
products = []
current_id = context.get('product_template_id')
if current_id:
current_id = int(current_id)
def _get_products_recently_sold_with(
self, website, limit, domain, product_template_id, **kwargs,
):
products = self.env['product.product']
current_template = self.env['product.template'].browse(
product_template_id and int(product_template_id)
).exists()
if current_template:
sale_orders = self.env['sale.order'].sudo().search([
('website_id', '=', website.id),
('state', 'in', ('sale', 'done')),
('order_line.product_id.product_tmpl_id', '=', current_id),
('company_id', '=', website.company_id.id),
('state', '=', 'sale'),
('order_line.product_id.product_tmpl_id', '=', current_template.id),
], limit=8, order='date_order DESC')
if sale_orders:
current_template = self.env['product.template'].browse(current_id)
excluded_products = website.sale_get_order().order_line.product_id.product_tmpl_id.product_variant_ids.ids
excluded_products.extend(current_template.product_variant_ids.ids)
included_products = []
for sale_order in sale_orders:
included_products.extend(sale_order.order_line.product_id.ids)
products_ids = list(set(included_products) - set(excluded_products))
if products_ids:
domain = expression.AND([
domain,
[('id', 'in', products_ids)],
])
products = self.env['product.product'].with_context(display_default_code=False).search(domain, limit=limit)
cart_products = request.cart.order_line.product_id
excluded_products = cart_products.product_tmpl_id.product_variant_ids
excluded_products |= current_template.product_variant_ids
included_products = sale_orders.order_line.product_id
if self.env.context.get('hide_variants'):
included_products = included_products.product_tmpl_id.product_variant_id
if products := included_products - excluded_products:
domain = Domain(domain) & Domain('id', 'in', products.ids)
products = self.env['product.product'].with_context(
display_default_code=False,
).search(domain, limit=limit)
return products
def _get_products_accessories(self, website, limit, domain, context):
products = []
current_id = context.get('product_template_id')
if current_id:
current_id = int(current_id)
current_template = self.env['product.template'].browse(current_id)
if current_template.exists():
excluded_products = website.sale_get_order().order_line.product_id.ids
excluded_products.extend(current_template.product_variant_ids.ids)
included_products = current_template._get_website_accessory_product().ids
products_ids = list(set(included_products) - set(excluded_products))
if products_ids:
domain = expression.AND([
domain,
[('id', 'in', products_ids)],
])
products = self.env['product.product'].with_context(display_default_code=False).search(domain, limit=limit)
return products
def _get_products_alternative_products(self, website, limit, domain, context):
def _get_products_accessories(self, website, limit, domain, product_template_id=None, **kwargs):
products = self.env['product.product']
current_id = context.get('product_template_id')
if not current_id:
return products
current_template = self.env['product.template'].browse(int(current_id))
if current_template.exists():
excluded_products = website.sale_get_order().order_line.product_id
current_template = self.env['product.template'].browse(
product_template_id and int(product_template_id)
).exists()
if current_template:
cart_products = request.cart.order_line.product_id
excluded_products = cart_products.product_tmpl_id.product_variant_ids
excluded_products |= current_template.product_variant_ids
included_products = current_template.alternative_product_ids.product_variant_ids
products = included_products - excluded_products
if website.prevent_zero_price_sale:
products = products.filtered(lambda p: p._get_contextual_price())
if products:
domain = expression.AND([
domain,
[('id', 'in', products.ids)],
])
products = self.env['product.product'].with_context(display_default_code=False).search(domain, limit=limit)
included_products = current_template._get_website_accessory_product()
if self.env.context.get('hide_variants'):
included_products = included_products.product_tmpl_id.product_variant_id
if products := included_products - excluded_products:
domain = Domain(domain) & Domain('id', 'in', products.ids)
products = self.env['product.product'].with_context(
display_default_code=False,
).search(domain, limit=limit)
return products
def _get_products_alternative_products(
self, website, limit, domain, product_template_id=None, **kwargs,
):
products = self.env['product.product']
current_template = self.env['product.template'].browse(
product_template_id and int(product_template_id)
).exists()
if current_template:
cart_products = request.cart.order_line.product_id
excluded_products = cart_products.product_tmpl_id.product_variant_ids
excluded_products |= current_template.product_variant_ids
alternative_products = current_template._get_website_alternative_product()
if self.env.context.get('hide_variants'):
included_products = alternative_products.product_variant_id
else:
included_products = alternative_products.product_variant_ids
products = included_products - excluded_products
if products:
domain = Domain(domain) & Domain('id', 'in', products.ids)
products = self.env['product.product'].with_context(
display_default_code=False,
).search(domain, limit=limit)
return products
@api.model
def default_get(self, fields):
defaults = super().default_get(fields)
if 'field_names' in defaults and self.env.context.get('model') == 'product.product':
defaults['field_names'] = 'display_name,description_sale,image_512'
return defaults

View file

@ -0,0 +1,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class WebsiteTrack(models.Model):
_inherit = 'website.track'
product_id = fields.Many2one(
comodel_name='product.product', ondelete='cascade', readonly=True, index='btree_not_null',
)

View file

@ -1,36 +1,37 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from odoo import fields, models, api
class WebsiteTrack(models.Model):
_inherit = 'website.track'
product_id = fields.Many2one('product.product', ondelete='cascade', readonly=True, index='btree_not_null')
from odoo import api, fields, models
class WebsiteVisitor(models.Model):
_inherit = 'website.visitor'
visitor_product_count = fields.Integer('Product Views', compute="_compute_product_statistics", help="Total number of views on products")
product_ids = fields.Many2many('product.product', string="Visited Products", compute="_compute_product_statistics")
product_count = fields.Integer('Products Views', compute="_compute_product_statistics", help="Total number of product viewed")
visitor_product_count = fields.Integer(
string="Product Views",
help="Total number of views on products",
compute='_compute_product_statistics',
)
product_ids = fields.Many2many(
string="Visited Products",
comodel_name='product.product',
compute='_compute_product_statistics',
)
product_count = fields.Integer(
string='Products Views',
help="Total number of product viewed",
compute='_compute_product_statistics',
)
@api.depends('website_track_ids')
def _compute_product_statistics(self):
results = self.env['website.track']._read_group(
[('visitor_id', 'in', self.ids), ('product_id', '!=', False),
'|', ('product_id.company_id', 'in', self.env.companies.ids), ('product_id.company_id', '=', False)],
['visitor_id', 'product_id'], ['visitor_id', 'product_id'],
lazy=False)
mapped_data = {}
for result in results:
visitor_info = mapped_data.get(result['visitor_id'][0], {'product_count': 0, 'product_ids': set()})
visitor_info['product_count'] += result['__count']
visitor_info['product_ids'].add(result['product_id'][0])
mapped_data[result['visitor_id'][0]] = visitor_info
results = self.env['website.track']._read_group([
('visitor_id', 'in', self.ids), ('product_id', '!=', False),
('product_id', 'any', self.env['product.product']._check_company_domain(self.env.companies)),
], ['visitor_id'], ['product_id:array_agg', '__count'])
mapped_data = {
visitor.id: {'product_count': count, 'product_ids': product_ids}
for visitor, product_ids, count in results
}
for visitor in self:
visitor_info = mapped_data.get(visitor.id, {'product_ids': [], 'product_count': 0})