mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-26 10:52:05 +02:00
1092 lines
45 KiB
Python
1092 lines
45 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
|
|
from lxml import etree
|
|
from werkzeug import urls
|
|
from werkzeug.exceptions import NotFound
|
|
|
|
from odoo import SUPERUSER_ID, api, fields, models
|
|
from odoo.exceptions import AccessError, MissingError
|
|
from odoo.fields import Domain
|
|
from odoo.http import request
|
|
from odoo.tools import file_open, ormcache
|
|
from odoo.tools.translate import LazyTranslate, _
|
|
|
|
from odoo.addons.website_sale import const
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_lt = LazyTranslate(__name__)
|
|
|
|
|
|
CART_SESSION_CACHE_KEY = 'sale_order_id'
|
|
FISCAL_POSITION_SESSION_CACHE_KEY = 'fiscal_position_id'
|
|
PRICELIST_SESSION_CACHE_KEY = 'website_sale_current_pl'
|
|
PRICELIST_SELECTED_SESSION_CACHE_KEY = 'website_sale_selected_pl_id'
|
|
|
|
|
|
class Website(models.Model):
|
|
_inherit = 'website'
|
|
|
|
#=== DEFAULT METHODS ===#
|
|
|
|
def _default_salesteam_id(self):
|
|
team = self.env.ref('sales_team.salesteam_website_sales', raise_if_not_found=False)
|
|
if team and team.active:
|
|
return team.id
|
|
return None
|
|
|
|
def _default_recovery_mail_template(self):
|
|
try:
|
|
return self.env.ref('website_sale.mail_template_sale_cart_recovery').id
|
|
except ValueError:
|
|
return False
|
|
|
|
def _default_confirmation_email_template(self):
|
|
template_id = self.env['ir.config_parameter'].sudo().get_param(
|
|
'sale.default_confirmation_template'
|
|
)
|
|
default_template = template_id and self.env['mail.template'].browse(int(template_id)).exists()
|
|
if default_template:
|
|
return default_template
|
|
return self.env.ref('sale.mail_template_sale_confirmation', raise_if_not_found=False)
|
|
|
|
#=== FIELDS ===#
|
|
|
|
salesperson_id = fields.Many2one(
|
|
string="Salesperson",
|
|
comodel_name='res.users',
|
|
domain=[('share', '=', False)],
|
|
)
|
|
salesteam_id = fields.Many2one(
|
|
string="Sales Team",
|
|
comodel_name='crm.team',
|
|
index='btree_not_null',
|
|
ondelete='set null',
|
|
default=_default_salesteam_id,
|
|
)
|
|
show_line_subtotals_tax_selection = fields.Selection(
|
|
string="Line Subtotals Tax Display",
|
|
selection=[
|
|
('tax_excluded', "Tax Excluded"),
|
|
('tax_included', "Tax Included"),
|
|
],
|
|
compute='_compute_show_line_subtotals_tax_selection',
|
|
readonly=False,
|
|
store=True,
|
|
)
|
|
|
|
add_to_cart_action = fields.Selection(
|
|
selection=[
|
|
('stay', "Stay on Product Page"),
|
|
('go_to_cart', "Go to cart"),
|
|
],
|
|
default='stay',
|
|
)
|
|
auth_signup_uninvited = fields.Selection(default='b2c')
|
|
account_on_checkout = fields.Selection(
|
|
string="Customer Accounts",
|
|
selection=[
|
|
('optional', "Optional"),
|
|
('disabled', "Disabled (buy as guest)"),
|
|
('mandatory', "Mandatory (no guest checkout)"),
|
|
],
|
|
default='optional',
|
|
)
|
|
cart_recovery_mail_template_id = fields.Many2one(
|
|
string="Cart Recovery Email",
|
|
comodel_name='mail.template',
|
|
domain=[('model', '=', 'sale.order')],
|
|
default=_default_recovery_mail_template,
|
|
)
|
|
contact_us_button_url = fields.Char(
|
|
string="Contact Us Button URL", translate=True, default="/contactus",
|
|
)
|
|
cart_abandoned_delay = fields.Float(string="Abandoned Delay", default=10.0)
|
|
send_abandoned_cart_email = fields.Boolean(
|
|
string="Send email to customers who abandoned their cart.",
|
|
)
|
|
send_abandoned_cart_email_activation_time = fields.Datetime(
|
|
string="Time when the 'Send abandoned cart email' feature was activated.",
|
|
compute='_compute_send_abandoned_cart_email_activation_time',
|
|
store=True,
|
|
)
|
|
shop_page_container = fields.Selection(
|
|
selection=[
|
|
('regular', "Regular"),
|
|
('fluid', "Full-width"),
|
|
],
|
|
default='regular',
|
|
)
|
|
shop_ppg = fields.Integer(
|
|
string="Number of products in the grid on the shop", default=21,
|
|
)
|
|
shop_ppr = fields.Integer(string="Number of grid columns on the shop", default=3)
|
|
|
|
shop_gap = fields.Char(string="Grid-gap on the shop", default="16px", required=False)
|
|
|
|
shop_opt_products_design_classes = fields.Char(
|
|
string="Shop Design Class",
|
|
default=(
|
|
'o_wsale_products_opt_layout_catalog o_wsale_products_opt_design_thumbs '
|
|
'o_wsale_products_opt_name_color_regular o_wsale_products_opt_rounded_2 '
|
|
'o_wsale_products_opt_thumb_cover o_wsale_products_opt_img_secondary_show '
|
|
'o_wsale_products_opt_img_hover_zoom_out_light o_wsale_products_opt_has_cta '
|
|
'o_wsale_products_opt_actions_onhover o_wsale_products_opt_has_wishlist '
|
|
'o_wsale_products_opt_wishlist_fixed o_wsale_products_opt_has_description '
|
|
'o_wsale_products_opt_actions_subtle o_wsale_products_opt_cc1'
|
|
),
|
|
help="CSS class for shop products design"
|
|
)
|
|
|
|
shop_default_sort = fields.Selection(
|
|
selection='_get_product_sort_mapping', required=True, default='website_sequence asc')
|
|
|
|
shop_extra_field_ids = fields.One2many(
|
|
string="E-Commerce Extra Fields",
|
|
comodel_name='website.sale.extra.field',
|
|
inverse_name='website_id',
|
|
)
|
|
|
|
product_page_container = fields.Selection(
|
|
selection=[
|
|
('unset', "Unset"),
|
|
('regular', "Regular"),
|
|
('fluid', "Full-width"),
|
|
],
|
|
default='unset'
|
|
)
|
|
|
|
product_page_cols_order = fields.Selection(
|
|
selection=[
|
|
('regular', "Regular order"),
|
|
('inverse', "Inverse order"),
|
|
],
|
|
string="Product Page main columns order",
|
|
default='regular',
|
|
)
|
|
|
|
product_page_image_layout = fields.Selection(
|
|
selection=[
|
|
('carousel', "Carousel"),
|
|
('grid', "Grid"),
|
|
],
|
|
required=True,
|
|
default='carousel',
|
|
)
|
|
product_page_image_width = fields.Selection(
|
|
selection=[
|
|
('none', "Hidden"),
|
|
('33_pc', "33 %"),
|
|
('50_pc', "50 %"),
|
|
('66_pc', "66 %"),
|
|
('100_pc', "100 %"),
|
|
],
|
|
required=True,
|
|
default='50_pc',
|
|
)
|
|
product_page_image_spacing = fields.Selection(
|
|
selection=[
|
|
('none', "None"),
|
|
('small', "Small"),
|
|
('medium', "Medium"),
|
|
('big', "Big"),
|
|
],
|
|
required=True,
|
|
default='none',
|
|
)
|
|
product_page_image_roundness = fields.Selection(
|
|
selection=[
|
|
('none', "None"),
|
|
('small', "Small"),
|
|
('medium', "Medium"),
|
|
('big', "Big"),
|
|
],
|
|
required=True,
|
|
default='none',
|
|
)
|
|
product_page_image_ratio = fields.Selection(
|
|
selection=[
|
|
('auto', "Auto"),
|
|
('21_9', "Wider (21/9)"),
|
|
('16_9', "Wide (16/9)"),
|
|
('4_3', "Landscape (4/3)"),
|
|
('6_5', "Horizontal (6/5)"),
|
|
('1_1', "Default (1/1)"),
|
|
('4_5', "Portrait (4/5)"),
|
|
('2_3', "Vertical (2/3)"),
|
|
],
|
|
required=True,
|
|
default='1_1',
|
|
)
|
|
product_page_image_ratio_mobile = fields.Selection(
|
|
selection=[
|
|
('auto', "Auto"),
|
|
('21_9', "Wider (21/9)"),
|
|
('16_9', "Wide (16/9)"),
|
|
('4_3', "Landscape (4/3)"),
|
|
('6_5', "Horizontal (6/5)"),
|
|
('1_1', "Default (1/1)"),
|
|
('4_5', "Portrait (4/5)"),
|
|
('2_3', "Vertical (2/3)"),
|
|
],
|
|
required=True,
|
|
default='auto',
|
|
)
|
|
ecommerce_access = fields.Selection(
|
|
selection=[
|
|
('everyone', "All users"),
|
|
('logged_in', "Logged in users"),
|
|
],
|
|
required=True,
|
|
default='everyone',
|
|
)
|
|
product_page_grid_columns = fields.Integer(default=2)
|
|
|
|
prevent_zero_price_sale = fields.Boolean(string="Hide 'Add To Cart' when price = 0")
|
|
|
|
enabled_gmc_src = fields.Boolean(
|
|
string="Google Merchant Center",
|
|
default=lambda self: self.env['res.groups']._is_feature_enabled(
|
|
'website_sale.group_product_feed',
|
|
),
|
|
)
|
|
|
|
currency_id = fields.Many2one(
|
|
string="Default Currency",
|
|
comodel_name='res.currency',
|
|
compute='_compute_currency_id',
|
|
)
|
|
pricelist_ids = fields.One2many(
|
|
string="Price list available for this Ecommerce/Website",
|
|
comodel_name='product.pricelist',
|
|
compute="_compute_pricelist_ids",
|
|
)
|
|
confirmation_email_template_id = fields.Many2one(
|
|
comodel_name='mail.template',
|
|
domain=[('model', '=', 'sale.order')],
|
|
default=_default_confirmation_email_template,
|
|
)
|
|
|
|
#=== COMPUTE METHODS ===#
|
|
|
|
def _compute_pricelist_ids(self):
|
|
for website in self:
|
|
website = website.with_company(website.company_id)
|
|
ProductPricelist = website.env['product.pricelist'] # with correct company in env
|
|
website.pricelist_ids = ProductPricelist.sudo().search_fetch(
|
|
ProductPricelist._get_website_pricelists_domain(website)
|
|
)
|
|
|
|
@api.depends('company_id')
|
|
def _compute_currency_id(self):
|
|
for website in self:
|
|
website.currency_id = (
|
|
request and hasattr(request, 'pricelist') and request.pricelist.currency_id
|
|
or website.company_id.sudo().currency_id
|
|
)
|
|
|
|
@api.depends('send_abandoned_cart_email')
|
|
def _compute_send_abandoned_cart_email_activation_time(self):
|
|
for website in self:
|
|
if website.send_abandoned_cart_email:
|
|
website.send_abandoned_cart_email_activation_time = fields.Datetime.now()
|
|
|
|
@api.depends('company_id.account_fiscal_country_id')
|
|
def _compute_show_line_subtotals_tax_selection(self):
|
|
for website in self:
|
|
website.show_line_subtotals_tax_selection = 'tax_excluded'
|
|
|
|
#=== SELECTION METHODS ===#
|
|
|
|
@staticmethod
|
|
def _get_product_sort_mapping():
|
|
return [
|
|
('website_sequence asc', _("Featured")),
|
|
('publish_date desc', _("Newest Arrivals")),
|
|
('name asc', _("Name (A-Z)")),
|
|
('list_price asc', _("Price - Low to High")),
|
|
('list_price desc', _("Price - High to Low")),
|
|
]
|
|
|
|
#=== BUSINESS METHODS ===#
|
|
|
|
@api.model
|
|
def get_configurator_shop_page_styles(self):
|
|
"""Format and return the ids and images of each shop page style for website onboarding.
|
|
|
|
:return: The shop page style information.
|
|
:rtype: list[dict]
|
|
"""
|
|
return [
|
|
{'option': option, 'img_src': config['img_src'], 'title': config['title']}
|
|
for option, config in const.SHOP_PAGE_STYLE_MAPPING.items()
|
|
]
|
|
|
|
@api.model
|
|
def get_configurator_product_page_styles(self):
|
|
"""Format and return ids and images of each product page style for website onboarding.
|
|
|
|
:return: The product page style information.
|
|
:rtype: list[dict]
|
|
"""
|
|
return [
|
|
{'option': option, 'img_src': config['img_src'], 'title': config['title']}
|
|
for option, config in const.PRODUCT_PAGE_STYLE_MAPPING.items()
|
|
]
|
|
|
|
@api.model
|
|
def configurator_apply(
|
|
self, *, shop_page_style_option=None, product_page_style_option=None, **kwargs
|
|
):
|
|
"""Override of `website` to apply eCommerce page style configurations.
|
|
|
|
:param str shop_page_style_option: The key of the selected shop page style option. See
|
|
`const.SHOP_PAGE_STYLE_MAPPING`.
|
|
:param str product_page_style_option: The key of the selected product page style option. See
|
|
`const.PRODUCT_PAGE_STYLE_MAPPING`.
|
|
"""
|
|
res = super().configurator_apply(**kwargs)
|
|
|
|
website = self.get_current_website()
|
|
website_settings = {}
|
|
category_settings = {}
|
|
views_to_disable = []
|
|
views_to_enable = []
|
|
scss_customization_params = {}
|
|
ThemeUtils = self.env['theme.utils'].with_context(website_id=website.id)
|
|
Assets = self.env['website.assets']
|
|
|
|
def parse_style_config(style_config_):
|
|
website_settings.update(style_config_['website_fields'])
|
|
category_settings.update(style_config_.get('category_fields', {}))
|
|
views_to_disable.extend(style_config_['views']['disable'])
|
|
views_to_enable.extend(style_config_['views']['enable'])
|
|
scss_customization_params.update(style_config_.get('scss_customization_params', {}))
|
|
|
|
# Extract shop page settings.
|
|
if shop_page_style_option:
|
|
style_config = const.SHOP_PAGE_STYLE_MAPPING[shop_page_style_option]
|
|
parse_style_config(style_config)
|
|
|
|
# Extract product page settings.
|
|
if product_page_style_option:
|
|
style_config = const.PRODUCT_PAGE_STYLE_MAPPING[product_page_style_option]
|
|
parse_style_config(style_config)
|
|
|
|
# Apply eCommerce page style configurations.
|
|
if website_settings:
|
|
website.write(website_settings)
|
|
if category_settings:
|
|
self.env['product.public.category'].search(website.website_domain()).write(
|
|
category_settings
|
|
)
|
|
for xml_id in views_to_disable:
|
|
ThemeUtils.disable_view(xml_id)
|
|
for xml_id in views_to_enable:
|
|
ThemeUtils.enable_view(xml_id)
|
|
|
|
for footer_id in ThemeUtils._footer_templates:
|
|
footer_view = self.with_context(website_id=website.id).viewref(
|
|
footer_id,
|
|
raise_if_not_found=False, # don't raise on custom footers not installed on website
|
|
)
|
|
if not footer_view.active:
|
|
continue
|
|
|
|
footer_updated = False
|
|
try:
|
|
arch_tree = etree.fromstring(footer_view.arch)
|
|
except etree.XMLSyntaxError as e:
|
|
logger.warning("Failed to update ecommerce footer view %s: %s", footer_id, e)
|
|
else:
|
|
# TODO this should be moved as a website feature (not eCommerce-specific)
|
|
footer_div_node = arch_tree.xpath(
|
|
"//section/div[hasclass('container') or hasclass('o_container_small') or hasclass('container-fluid')]",
|
|
)
|
|
# The xml view could have been modified in the backend, we don't
|
|
# want the xpath error to break the configurator feature
|
|
if not footer_div_node:
|
|
logger.warning(
|
|
"Failed to match footer width with header in ecommerce footer view %s",
|
|
footer_id,
|
|
)
|
|
else:
|
|
# Logic for matching header width
|
|
if 'website.footer_copyright_content_width_fluid' in views_to_enable:
|
|
footer_updated = True
|
|
footer_div_node[0].set("class", "container-fluid s_allow_columns")
|
|
elif 'website.footer_copyright_content_width_small' in views_to_enable:
|
|
footer_updated = True
|
|
footer_div_node[0].set("class", "o_container_small s_allow_columns")
|
|
|
|
if footer_id == 'website_sale.template_footer_website_sale':
|
|
ecommerce_categories_node = arch_tree.xpath("//t[@t-set='ecommerce_categories']")
|
|
if not ecommerce_categories_node:
|
|
logger.warning("Skipping ecommerce categories in ecommerce footer view %s", footer_id)
|
|
else:
|
|
# Logic for inserting eCommerce categories in footer
|
|
ecommerce_categories = self.env['product.public.category'].search([], limit=6)
|
|
# Deliberately hardcode categories inside the view arch, it will be transformed into
|
|
# static nodes after a save/edit thanks to the t-ignore in parent node.
|
|
footer_updated = True
|
|
ecommerce_categories_node[0].attrib['t-value'] = json.dumps([
|
|
{
|
|
'name': cat.name,
|
|
'id': cat.id,
|
|
}
|
|
for cat in ecommerce_categories
|
|
])
|
|
|
|
if footer_updated:
|
|
footer_view.write({'arch': etree.tostring(arch_tree)})
|
|
|
|
if 'website_sale.template_footer_website_sale' in views_to_enable:
|
|
scss_customization_params['footer-template'] = 'website_sale'
|
|
|
|
# For a website editor to recognize the correct header/footer templates
|
|
# (reason `isApplied` method of footer plugin)
|
|
if scss_customization_params:
|
|
Assets.make_scss_customization(
|
|
'/website/static/src/scss/options/user_values.scss',
|
|
scss_customization_params,
|
|
)
|
|
|
|
return res
|
|
|
|
def configurator_addons_apply(self, industry_name=None, **kwargs):
|
|
"""Override of `website` to generate eCommerce categories for a given industry using AI."""
|
|
|
|
def generate_categories(industry_name_):
|
|
lang = self.env.context.get('lang')
|
|
prompt = (
|
|
f"You are a seasoned Marketing Expert specializing in crafting high-converting eCommerce experiences.\n"
|
|
f"Your task is to develop compelling category names and descriptions for a {industry_name_}'s new online store.\n"
|
|
f"The goal is to create categories that are persuasive, attention-grabbing, and concise, encouraging visitors to explore the offerings.\n"
|
|
f"All content should be in {lang}.\n"
|
|
f"Here's the format you will use to generate the categories:\n"
|
|
f'{{"categories": ['
|
|
f'{{"name": "$category_name_1", "description": "$category_description_1"}}, '
|
|
f'{{"name": "$category_name_2", "description": "$category_description_2"}}, '
|
|
f'{{"name": "$category_name_3", "description": "$category_description_3"}}, '
|
|
f'{{"name": "$category_name_4", "description": "$category_description_4"}}, '
|
|
f'{{"name": "$category_name_5", "description": "$category_description_5"}}, '
|
|
f'{{"name": "$category_name_6", "description": "$category_description_6"}}, '
|
|
f'{{"name": "$category_name_7", "description": "$category_description_7"}}, '
|
|
f'{{"name": "$category_name_8", "description": "$category_description_8"}}'
|
|
f']}}\n'
|
|
f"Constraints:\n"
|
|
f"Language: {lang}\n"
|
|
f"Category Names: Must be nouns only (no adjectives).\n"
|
|
f"Description Length: Keep descriptions very short and to the point (ideally under 20 words).\n"
|
|
f"Persuasion: Descriptions should be persuasive and designed to attract attention.\n"
|
|
f"Number of Categories: Exactly 8 categories are required.\n"
|
|
f"Now, generate the 8 eCommerce categories for the {industry_name_}, adhering to the specified format and constraints."
|
|
)
|
|
IrConfigParameterSudo = self.env['ir.config_parameter'].sudo()
|
|
database_id = IrConfigParameterSudo.get_param('database.uuid')
|
|
try:
|
|
response = self._OLG_api_rpc('/api/olg/1/chat', {
|
|
'prompt': prompt,
|
|
'conversation_history': [],
|
|
'database_id': database_id,
|
|
})
|
|
except AccessError:
|
|
logger.warning("API is unreachable for the category generation")
|
|
return None
|
|
|
|
if response['status'] == 'success':
|
|
content = response['content'].replace('```json\n', '').replace('\n```', '')
|
|
try:
|
|
return json.loads(content)
|
|
except json.JSONDecodeError:
|
|
logger.warning("API response is not a valid JSON for the category generation")
|
|
elif response['status'] == 'error_prompt_too_long':
|
|
logger.warning("Prompt is too long for the category generation")
|
|
elif response['status'] == 'limit_call_reached':
|
|
logger.warning("Limit call reached for the category generation")
|
|
else:
|
|
logger.warning("Response could not be generated for the category generation")
|
|
return None
|
|
|
|
res = super().configurator_addons_apply(industry_name=industry_name, **kwargs)
|
|
|
|
if self.env['product.public.category'].search_count([], limit=1):
|
|
logger.info("Categories already exist, skipping AI generation.")
|
|
return
|
|
|
|
category_specs = generate_categories(industry_name)
|
|
if not isinstance(category_specs, dict):
|
|
return
|
|
|
|
if len(category_specs.get('categories')) == 8:
|
|
images_names = [f'shape_mixed_{i}.png' for i in range(1, 9)]
|
|
categories = []
|
|
for idx, cat in enumerate(category_specs['categories']):
|
|
image_name = images_names[idx]
|
|
img_path = 'website_sale/static/src/img/categories/' + image_name
|
|
with file_open(img_path, 'rb') as file:
|
|
image_base64 = base64.b64encode(file.read())
|
|
categories.append({
|
|
'name': cat['name'],
|
|
'website_description': cat['description'],
|
|
'image_1920': image_base64,
|
|
'cover_image': image_base64,
|
|
})
|
|
self.env['product.public.category'].sudo().create(categories)
|
|
return res
|
|
|
|
# This method is cached, must not return records! See also #8795
|
|
@ormcache(
|
|
'country_code', 'show_visible', 'current_pl_id', 'website_pricelist_ids', 'partner_pl_id',
|
|
)
|
|
def _get_pl_partner_order(
|
|
self, country_code, show_visible, current_pl_id, website_pricelist_ids, partner_pl_id=False
|
|
):
|
|
""" Return the list of pricelists that can be used on website for the current user.
|
|
|
|
:param str country_code: code iso or False, If set, we search only price list available for this country
|
|
:param bool show_visible: if True, we don't display pricelist where selectable is False (Eg: Code promo)
|
|
:param int current_pl_id: The current pricelist used on the website
|
|
(If not selectable but currently used anyway, e.g. pricelist with promo code)
|
|
:param tuple website_pricelist_ids: List of ids of pricelists available for this website
|
|
:param int partner_pl_id: the partner pricelist
|
|
:returns: list of product.pricelist ids
|
|
:rtype: list
|
|
"""
|
|
self.ensure_one()
|
|
pricelists = self.env['product.pricelist']
|
|
|
|
def check_pricelist(pricelist):
|
|
if show_visible:
|
|
return pricelist.selectable or pricelist.id == current_pl_id
|
|
else:
|
|
return True
|
|
|
|
# Note: 1. pricelists from all_pl are already website compliant (went through
|
|
# `_get_website_pricelists_domain`)
|
|
# 2. do not read `property_product_pricelist` here as `_get_pl_partner_order`
|
|
# is cached and the result of this method will be impacted by that field value.
|
|
# Pass it through `partner_pl_id` parameter instead to invalidate the cache.
|
|
|
|
# If there is a GeoIP country, find a pricelist for it
|
|
if country_code:
|
|
pricelists |= self.env['res.country.group'].search(
|
|
[('country_ids.code', '=', country_code)]
|
|
).pricelist_ids.filtered(
|
|
lambda pl: pl._is_available_on_website(self) and check_pricelist(pl)
|
|
)
|
|
|
|
# no GeoIP or no pricelist for this country
|
|
if not pricelists:
|
|
pricelists = pricelists.browse(website_pricelist_ids).filtered(
|
|
lambda pl: check_pricelist(pl) and not (country_code and pl.country_group_ids))
|
|
|
|
# if logged in, add partner pl (which is `property_product_pricelist`, might not be website compliant)
|
|
if not self.env.user._is_public():
|
|
# keep partner_pricelist only if website compliant
|
|
partner_pricelist = pricelists.browse(partner_pl_id).filtered(
|
|
lambda pl:
|
|
pl._is_available_on_website(self)
|
|
and check_pricelist(pl)
|
|
and pl._is_available_in_country(country_code)
|
|
)
|
|
pricelists |= partner_pricelist
|
|
|
|
# This method is cached, must not return records! See also #8795
|
|
# sudo is needed to ensure no records rules are applied during the sorted call,
|
|
# we only want to reorder the records on hand, not filter them.
|
|
return pricelists.sudo().sorted().ids
|
|
|
|
def get_pricelist_available(self, show_visible=False):
|
|
""" Return the list of pricelists that can be used on website for the current user.
|
|
Country restrictions will be detected with GeoIP (if installed).
|
|
:param bool show_visible: if True, we don't display pricelist where selectable is False (Eg: Code promo)
|
|
:returns: pricelist recordset
|
|
"""
|
|
self.ensure_one()
|
|
|
|
ProductPricelist = self.env['product.pricelist']
|
|
|
|
if not self.env['res.groups']._is_feature_enabled('product.group_product_pricelist'):
|
|
return ProductPricelist # Skip pricelist computation if pricelists are disabled.
|
|
|
|
country_code = self._get_geoip_country_code()
|
|
website = self.with_company(self.company_id)
|
|
|
|
partner_sudo = website.env.user.partner_id
|
|
is_user_public = self.env.user._is_public()
|
|
if not is_user_public:
|
|
# Don't needlessly trigger `depends_context` recompute
|
|
ctx = {'country_code': country_code} if country_code else {}
|
|
partner_pricelist_id = partner_sudo.with_context(**ctx).property_product_pricelist.id
|
|
else: # public user: do not compute partner pl (not used)
|
|
partner_pricelist_id = False
|
|
website_pricelists = website.sudo().pricelist_ids
|
|
|
|
current_pricelist_id = request and request.session.get(PRICELIST_SESSION_CACHE_KEY) or None
|
|
|
|
pricelist_ids = website._get_pl_partner_order(
|
|
country_code,
|
|
show_visible,
|
|
current_pl_id=current_pricelist_id,
|
|
website_pricelist_ids=tuple(website_pricelists.ids),
|
|
partner_pl_id=partner_pricelist_id,
|
|
)
|
|
|
|
return ProductPricelist.browse(pricelist_ids)
|
|
|
|
def is_pricelist_available(self, pl_id):
|
|
""" Return a boolean to specify if a specific pricelist can be manually set on the website.
|
|
Warning: It check only if pricelist is in the 'selectable' pricelists or the current pricelist.
|
|
:param int pl_id: The pricelist id to check
|
|
:returns: Boolean, True if valid / available
|
|
"""
|
|
return pl_id in self.get_pricelist_available(show_visible=False).ids
|
|
|
|
def _get_geoip_country_code(self):
|
|
return request and request.geoip.country_code or False
|
|
|
|
def sale_product_domain(self):
|
|
website_domain = self.get_current_website().website_domain()
|
|
if self.env.user._is_internal():
|
|
user_domain = Domain.TRUE
|
|
else:
|
|
user_domain = [
|
|
('is_published', '=', True),
|
|
('service_tracking', 'in', self.env['product.template']._get_saleable_tracking_types()),
|
|
]
|
|
return Domain.AND([self._product_domain(), website_domain, user_domain])
|
|
|
|
def _product_domain(self):
|
|
return [('sale_ok', '=', True)]
|
|
|
|
def _create_cart(self):
|
|
self.ensure_one()
|
|
|
|
partner_sudo = self.env.user.partner_id
|
|
|
|
so_data = self._prepare_sale_order_values(partner_sudo)
|
|
sale_order_sudo = self.env['sale.order'].with_user(
|
|
SUPERUSER_ID
|
|
).with_company(self.company_id).create(so_data)
|
|
|
|
# The order was created with SUPERUSER_ID, revert back to request user.
|
|
sale_order_sudo = sale_order_sudo.with_user(self.env.user).sudo()
|
|
|
|
request.session[CART_SESSION_CACHE_KEY] = sale_order_sudo.id
|
|
request.session['website_sale_cart_quantity'] = sale_order_sudo.cart_quantity
|
|
request.cart = sale_order_sudo
|
|
|
|
return sale_order_sudo
|
|
|
|
def _prepare_sale_order_values(self, partner_sudo):
|
|
self.ensure_one()
|
|
|
|
return {
|
|
'company_id': self.company_id.id,
|
|
'partner_id': partner_sudo.id,
|
|
|
|
'fiscal_position_id': request.fiscal_position.id,
|
|
'pricelist_id': request.pricelist.id,
|
|
|
|
'team_id': self.salesteam_id.id,
|
|
'website_id': self.id,
|
|
}
|
|
|
|
def _get_and_cache_current_pricelist(self):
|
|
"""Retrieve and cache the current pricelist for the session.
|
|
|
|
Note: self.ensure_one()
|
|
|
|
:return: The determined pricelist, which could be empty, as a sudoed record.
|
|
:rtype: product.pricelist
|
|
"""
|
|
self.ensure_one()
|
|
|
|
ProductPricelistSudo = self.env['product.pricelist'].sudo()
|
|
if not self.env['res.groups']._is_feature_enabled('product.group_product_pricelist'):
|
|
return ProductPricelistSudo # Skip pricelist computation if pricelists are disabled.
|
|
|
|
if PRICELIST_SESSION_CACHE_KEY in request.session:
|
|
pricelist_sudo = ProductPricelistSudo.browse(
|
|
request.session[PRICELIST_SESSION_CACHE_KEY]
|
|
)
|
|
if pricelist_sudo and (
|
|
pricelist_sudo.exists()
|
|
and pricelist_sudo._is_available_on_website(self)
|
|
and pricelist_sudo._is_available_in_country(self._get_geoip_country_code())
|
|
):
|
|
return pricelist_sudo.sudo()
|
|
|
|
if cart_sudo := request.cart:
|
|
if not request.env.cr.readonly:
|
|
# If there is a cart, recompute on the cart and take it from there
|
|
cart_sudo._compute_pricelist_id()
|
|
pricelist_sudo = cart_sudo.pricelist_id
|
|
else:
|
|
pricelist_sudo = self.env.user.partner_id.property_product_pricelist
|
|
available_pricelists = self.get_pricelist_available()
|
|
if available_pricelists and pricelist_sudo not in available_pricelists:
|
|
pricelist_sudo = available_pricelists[0].sudo()
|
|
|
|
request.session[PRICELIST_SESSION_CACHE_KEY] = pricelist_sudo.id
|
|
|
|
return pricelist_sudo
|
|
|
|
def _get_and_cache_current_fiscal_position(self):
|
|
"""Retrieve and cache the current fiscal position for the session.
|
|
|
|
Note: self.ensure_one()
|
|
|
|
:return: A sudoed fiscal position record.
|
|
:rtype: account.fiscal.position
|
|
"""
|
|
self.ensure_one()
|
|
|
|
AccountFiscalPositionSudo = self.env['account.fiscal.position'].sudo()
|
|
fpos_sudo = AccountFiscalPositionSudo
|
|
|
|
if FISCAL_POSITION_SESSION_CACHE_KEY in request.session:
|
|
fpos_sudo = AccountFiscalPositionSudo.browse(
|
|
request.session[FISCAL_POSITION_SESSION_CACHE_KEY]
|
|
)
|
|
if fpos_sudo and fpos_sudo.exists():
|
|
return fpos_sudo
|
|
|
|
partner_sudo = self.env.user.partner_id
|
|
|
|
# If the current user is the website public user, the fiscal position
|
|
# is computed according to geolocation.
|
|
if request and request.geoip.country_code and self.partner_id.id == partner_sudo.id:
|
|
country = self.env['res.country'].search(
|
|
[('code', '=', request.geoip.country_code)],
|
|
limit=1,
|
|
)
|
|
partner_geoip = self.env['res.partner'].sudo().new({'country_id': country.id})
|
|
fpos_sudo = AccountFiscalPositionSudo._get_fiscal_position(partner_geoip)
|
|
|
|
if not fpos_sudo:
|
|
fpos_sudo = AccountFiscalPositionSudo._get_fiscal_position(partner_sudo)
|
|
|
|
request.session[FISCAL_POSITION_SESSION_CACHE_KEY] = fpos_sudo.id
|
|
|
|
return fpos_sudo
|
|
|
|
def _get_and_cache_current_cart(self):
|
|
""" Retrieves and caches the current cart for the session.
|
|
|
|
Note: self.ensure_one()
|
|
|
|
:return: A sudoed Sales order record.
|
|
:rtype: sale.order
|
|
"""
|
|
self.ensure_one()
|
|
|
|
SaleOrderSudo = self.env['sale.order'].sudo()
|
|
|
|
sale_order_sudo = SaleOrderSudo
|
|
if CART_SESSION_CACHE_KEY in request.session:
|
|
sale_order_sudo = SaleOrderSudo.browse(request.session[CART_SESSION_CACHE_KEY])
|
|
|
|
try:
|
|
# fetch the record field or raise a missingError
|
|
# avoids a query with the use of exists()
|
|
sale_order_sudo and sale_order_sudo.state
|
|
except MissingError:
|
|
self.sale_reset()
|
|
sale_order_sudo = SaleOrderSudo
|
|
|
|
if sale_order_sudo and (
|
|
sale_order_sudo.state != 'draft'
|
|
or sale_order_sudo.get_portal_last_transaction().state in (
|
|
'pending', 'authorized', 'done'
|
|
)
|
|
or sale_order_sudo.website_id != self
|
|
):
|
|
self.sale_reset()
|
|
sale_order_sudo = SaleOrderSudo
|
|
|
|
# If customer logs in, the cart must be recomputed based on his information (in the
|
|
# first non readonly request).
|
|
if (
|
|
sale_order_sudo
|
|
and not self.env.user._is_public()
|
|
and self.env.user.partner_id.id != sale_order_sudo.partner_id.id
|
|
and not request.env.cr.readonly
|
|
):
|
|
sale_order_sudo._update_address(self.env.user.partner_id.id, ['partner_id'])
|
|
elif (
|
|
self.env.user
|
|
and not self.env.user._is_public()
|
|
# If the company of the partner doesn't allow them to buy from this website, updating
|
|
# the cart customer would raise because of multi-company checks.
|
|
# No abandoned cart should be returned in this situation.
|
|
and self.env.user.partner_id.filtered_domain(
|
|
self.env['res.partner']._check_company_domain(self.company_id.id)
|
|
)
|
|
): # Search for abandonned cart.
|
|
partner_sudo = self.env.user.partner_id
|
|
abandonned_cart_sudo = SaleOrderSudo.search([
|
|
('partner_id', '=', partner_sudo.id),
|
|
('website_id', '=', self.id),
|
|
('state', '=', 'draft'),
|
|
], limit=1)
|
|
if abandonned_cart_sudo:
|
|
if not request.env.cr.readonly:
|
|
# Force the recomputation of the pricelist and fiscal position when resurrecting
|
|
# an abandonned cart
|
|
abandonned_cart_sudo._update_address(partner_sudo.id, ['partner_id'])
|
|
abandonned_cart_sudo._verify_cart()
|
|
sale_order_sudo = abandonned_cart_sudo
|
|
|
|
if (
|
|
(sale_order_sudo or not self.env.user._is_public())
|
|
and sale_order_sudo.id != request.session.get(CART_SESSION_CACHE_KEY)
|
|
):
|
|
# Store the id of the cart if there is one, or False if the user is logged in, to avoid
|
|
# searching for an abandoned cart again for that user.
|
|
request.session[CART_SESSION_CACHE_KEY] = sale_order_sudo.id
|
|
if 'website_sale_cart_quantity' not in request.session:
|
|
request.session['website_sale_cart_quantity'] = sale_order_sudo.cart_quantity
|
|
return sale_order_sudo
|
|
|
|
def sale_reset(self):
|
|
request.session.pop(CART_SESSION_CACHE_KEY, None)
|
|
request.session.pop('website_sale_cart_quantity', None)
|
|
request.session.pop(PRICELIST_SESSION_CACHE_KEY, None)
|
|
request.session.pop(FISCAL_POSITION_SESSION_CACHE_KEY, None)
|
|
request.session.pop(PRICELIST_SELECTED_SESSION_CACHE_KEY, None)
|
|
|
|
@api.model
|
|
def action_dashboard_redirect(self):
|
|
if self.env.user.has_group('sales_team.group_sale_salesman'):
|
|
return self.env['ir.actions.actions']._for_xml_id('website.backend_dashboard')
|
|
return super().action_dashboard_redirect()
|
|
|
|
def get_suggested_controllers(self):
|
|
suggested_controllers = super().get_suggested_controllers()
|
|
suggested_controllers.append((_('eCommerce'), self.env['ir.http']._url_for('/shop'), 'website_sale'))
|
|
return suggested_controllers
|
|
|
|
def _search_get_details(self, search_type, order, options):
|
|
result = super()._search_get_details(search_type, order, options)
|
|
if not self.has_ecommerce_access():
|
|
return result
|
|
if search_type in ['products', 'product_categories_only', 'all']:
|
|
result.append(self.env['product.public.category']._search_get_detail(self, order, options))
|
|
if search_type in ['products', 'products_only', 'all']:
|
|
result.append(self.env['product.template']._search_get_detail(self, order, options))
|
|
return result
|
|
|
|
def _get_product_page_proportions(self):
|
|
"""
|
|
Returns the number of columns (css) that both the images and the product details should take.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
return {
|
|
'none': (0, 12),
|
|
'50_pc': (6, 6),
|
|
'66_pc': (8, 4),
|
|
'100_pc': (12, 12),
|
|
}.get(self.product_page_image_width)
|
|
|
|
def _get_product_page_grid_image_spacing_classes(self):
|
|
spacing_map = {
|
|
'none': 'gap-0',
|
|
'small': 'gap-1',
|
|
'medium': 'gap-2',
|
|
'big': 'gap-3',
|
|
}
|
|
return spacing_map.get(self.product_page_image_spacing)
|
|
|
|
def _get_product_page_grid_image_rounded_classes(self):
|
|
roundness_map = {
|
|
'none': 'o_wsale_product_page_opt_image_radius_none',
|
|
'small': 'o_wsale_product_page_opt_image_radius_small',
|
|
'medium': 'o_wsale_product_page_opt_image_radius_medium',
|
|
'big': 'o_wsale_product_page_opt_image_radius_big',
|
|
}
|
|
return roundness_map.get(self.product_page_image_roundness)
|
|
|
|
def _get_product_page_container(self):
|
|
return self.shop_page_container if self.product_page_container == 'unset' else self.product_page_container
|
|
|
|
@api.model
|
|
def _send_abandoned_cart_email(self):
|
|
for website in self.search([]):
|
|
if not website.send_abandoned_cart_email:
|
|
continue
|
|
all_abandoned_carts = self.env['sale.order'].search([
|
|
('is_abandoned_cart', '=', True),
|
|
('cart_recovery_email_sent', '=', False),
|
|
('website_id', '=', website.id),
|
|
('date_order', '>=', website.send_abandoned_cart_email_activation_time),
|
|
])
|
|
if not all_abandoned_carts:
|
|
continue
|
|
|
|
abandoned_carts = all_abandoned_carts._filter_can_send_abandoned_cart_mail()
|
|
# Mark abandoned carts that failed the filter as sent to avoid rechecking them again and again.
|
|
(all_abandoned_carts - abandoned_carts).cart_recovery_email_sent = True
|
|
for sale_order in abandoned_carts:
|
|
template = self.env.ref('website_sale.mail_template_sale_cart_recovery')
|
|
# fallback email_vals in case partner_to and email_to were emptied
|
|
email_vals = {} if template.email_to or template.partner_to else {
|
|
'email_to': sale_order.partner_id.email_formatted
|
|
}
|
|
template.send_mail(sale_order.id, email_values=email_vals)
|
|
sale_order.cart_recovery_email_sent = True
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
websites = super().create(vals_list)
|
|
for website in websites:
|
|
website._create_checkout_steps()
|
|
return websites
|
|
|
|
def _create_checkout_steps(self):
|
|
generic_steps = self.env['website.checkout.step'].sudo().search([
|
|
('website_id', '=', False),
|
|
])
|
|
for step in generic_steps:
|
|
is_published = True
|
|
if step.step_href == '/shop/extra_info':
|
|
is_published = self.with_context(website_id=self.id).viewref('website_sale.extra_info').active
|
|
step.copy({'website_id': self.id, 'is_published': is_published})
|
|
|
|
def _get_checkout_step(self, href):
|
|
return self.env['website.checkout.step'].sudo().search([
|
|
('website_id', '=', self.id),
|
|
('step_href', '=', href),
|
|
], limit=1)
|
|
|
|
def _get_allowed_steps_domain(self):
|
|
return [
|
|
('website_id', '=', self.id),
|
|
('is_published', '=', True)
|
|
]
|
|
|
|
def _get_checkout_steps(self):
|
|
steps = self.env['website.checkout.step'].sudo().search(
|
|
self._get_allowed_steps_domain(), order='sequence'
|
|
)
|
|
return steps
|
|
|
|
def _get_checkout_step_values(self):
|
|
def rewrite(path):
|
|
return self.env['ir.http'].url_rewrite(path)[0]
|
|
href = rewrite(request.httprequest.path)
|
|
# /shop/address is associated with the delivery step
|
|
if href == rewrite('/shop/address'):
|
|
href = rewrite('/shop/checkout')
|
|
|
|
allowed_steps_domain = self._get_allowed_steps_domain()
|
|
current_step = request.env['website.checkout.step'].sudo()
|
|
for step in current_step.search(allowed_steps_domain):
|
|
if rewrite(step.step_href) == href:
|
|
current_step = step
|
|
href = step.step_href
|
|
break
|
|
next_step = current_step._get_next_checkout_step(allowed_steps_domain)
|
|
previous_step = current_step._get_previous_checkout_step(allowed_steps_domain)
|
|
|
|
next_href = next_step.step_href
|
|
# try_skip_step option required on /shop/checkout next button
|
|
if next_step.step_href == '/shop/checkout':
|
|
next_href = '/shop/checkout?try_skip_step=true'
|
|
# redirect handled by '/shop/address/submit' route when all values are properly filled
|
|
if request.httprequest.path == rewrite('/shop/address'):
|
|
next_href = False
|
|
|
|
return {
|
|
'current_website_checkout_step_href': href,
|
|
'previous_website_checkout_step': previous_step,
|
|
'next_website_checkout_step': next_step,
|
|
'next_website_checkout_step_href': next_href,
|
|
}
|
|
|
|
def has_ecommerce_access(self):
|
|
""" Return whether the current user is allowed to access eCommerce-related content. """
|
|
return not (self.env.user._is_public() and self.ecommerce_access == 'logged_in')
|
|
|
|
def _get_canonical_url(self):
|
|
""" Override of `website` to customize the canonical URL for product pages.
|
|
|
|
A product page URL can have a category in its path. However, since the page is exactly the
|
|
same whether the category is present or not, the canonical URL shouldn't include the
|
|
category.
|
|
"""
|
|
canonical_url = urls.url_parse(super()._get_canonical_url())
|
|
|
|
try:
|
|
rule = self.env['ir.http']._match(canonical_url.path)[0].rule
|
|
except NotFound:
|
|
rule = None
|
|
if rule == (
|
|
'/shop/<model("product.public.category"):category>/<model("product.template"):product>'
|
|
):
|
|
path_parts = canonical_url.path.split('/')
|
|
path_parts.pop(2)
|
|
canonical_url = canonical_url.replace(path='/'.join(path_parts))
|
|
return canonical_url.to_url()
|
|
|
|
def _get_snippet_defaults(self, snippet):
|
|
return super()._get_snippet_defaults(snippet) | const.SNIPPET_DEFAULTS.get(snippet, {})
|
|
|
|
def _get_product_image_ratio(self):
|
|
"""Get the product image aspect ratio based on the website's design classes.
|
|
|
|
Returns:
|
|
str: The aspect ratio as a string (e.g., '16_9', '4_3', '1_1')
|
|
"""
|
|
classes = self.shop_opt_products_design_classes or ''
|
|
ratio_mapping = {
|
|
'o_wsale_products_opt_thumb_16_9': '16_9',
|
|
'o_wsale_products_opt_thumb_4_3': '4_3',
|
|
'o_wsale_products_opt_thumb_6_5': '6_5',
|
|
'o_wsale_products_opt_thumb_4_5': '4_5',
|
|
'o_wsale_products_opt_thumb_2_3': '2_3',
|
|
}
|
|
for class_name, ratio in ratio_mapping.items():
|
|
if class_name in classes:
|
|
return ratio
|
|
return '1_1'
|
|
|
|
def _get_product_image_ratio_height(self):
|
|
match self._get_product_image_ratio():
|
|
case '16_9':
|
|
return '36px'
|
|
case '4_3':
|
|
return '48px'
|
|
case '6_5':
|
|
return '53px'
|
|
case '4_5':
|
|
return '96px'
|
|
return '64px'
|
|
|
|
def _get_basic_feed_product_domain(self):
|
|
return Domain.AND([
|
|
Domain('is_published', '=', True),
|
|
Domain('type', 'in', ('consu', 'combo')),
|
|
self.website_domain(),
|
|
])
|
|
|
|
def _default_feed_is_valid(self):
|
|
self.ensure_one()
|
|
product_count = self.env['product.product'].search_count(
|
|
self._get_basic_feed_product_domain(), limit=const.PRODUCT_FEED_SOFT_LIMIT + 1
|
|
)
|
|
return product_count <= const.PRODUCT_FEED_SOFT_LIMIT
|
|
|
|
def _populate_product_feeds(self):
|
|
"""Populate product feeds for the website with default values."""
|
|
self.env['product.feed'].create([
|
|
{
|
|
'name': website.env._("GMC 1"),
|
|
'website_id': website.id,
|
|
} for website in self.filtered(lambda w: w._default_feed_is_valid())
|
|
])
|