oca-ocb-sale/odoo-bringout-oca-ocb-website_sale/website_sale/models/website.py
Ernad Husremovic 73afc09215 19.0 vanilla
2026-03-09 09:32:12 +01:00

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())
])