mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-27 01:32:00 +02:00
19.0 vanilla
This commit is contained in:
parent
79f83631d5
commit
73afc09215
6267 changed files with 1534193 additions and 1130106 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
))
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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."""))
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class ProductTemplateAttributeValue(models.Model):
|
||||
_inherit = 'product.template.attribute.value'
|
||||
|
||||
def _get_extra_price(self, combination_info):
|
||||
self.ensure_one()
|
||||
if not self.price_extra:
|
||||
return 0.0
|
||||
|
||||
price_extra = self.price_extra
|
||||
if not price_extra:
|
||||
return price_extra
|
||||
|
||||
product_template = self.product_tmpl_id
|
||||
currency = combination_info['currency']
|
||||
if currency != product_template.currency_id:
|
||||
price_extra = self.currency_id._convert(
|
||||
from_amount=price_extra,
|
||||
to_currency=currency,
|
||||
company=self.env.company,
|
||||
date=combination_info['date'],
|
||||
)
|
||||
|
||||
product_taxes = combination_info['product_taxes']
|
||||
if product_taxes:
|
||||
price_extra = self.env['product.template']._apply_taxes_to_price(
|
||||
price_extra,
|
||||
combination_info['currency'],
|
||||
product_taxes,
|
||||
combination_info['taxes'],
|
||||
self.product_tmpl_id,
|
||||
)
|
||||
|
||||
return price_extra
|
||||
|
|
@ -1,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
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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]
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue