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

1994 lines
87 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import itertools
import json
from datetime import datetime
from werkzeug import urls
from werkzeug.exceptions import Forbidden, NotFound
from werkzeug.urls import url_decode, url_encode, url_parse
from odoo import fields
from odoo.exceptions import ValidationError
from odoo.fields import Command, Domain
from odoo.http import request, route
from odoo.tools import SQL, clean_context, float_round, groupby, lazy, str2bool
from odoo.tools.json import scriptsafe as json_scriptsafe
from odoo.tools.translate import LazyTranslate, _
from odoo.addons.payment import utils as payment_utils
from odoo.addons.payment.controllers import portal as payment_portal
from odoo.addons.sale.controllers import portal as sale_portal
from odoo.addons.html_editor.tools import get_video_thumbnail
from odoo.addons.website.controllers.main import QueryURL
from odoo.addons.website.models.ir_http import sitemap_qs2dom
from odoo.addons.website_sale.const import SHOP_PATH
from odoo.addons.website_sale.models.website import (
PRICELIST_SELECTED_SESSION_CACHE_KEY,
PRICELIST_SESSION_CACHE_KEY,
)
_lt = LazyTranslate(__name__)
def handle_product_params_error(exc, product, category=None, **kwargs):
""" Handle access and missing errors related to product or category on the eCommerce.
This function is intended to prevent access-related exceptions when a user attempts to view a
product or category page. It checks if the provided product and category records still exist and
are accessible, and then attempts to redirect to a valid fallback route if possible. If no valid
route is found, it returns a 404 response code (instead of a 403).
:param odoo.exceptions.AccessError | odoo.exceptions.MissingError exc: The exception thrown
by _check_access `base.models.ir_http._pre_dispatch`.
:param product.template product: The product the user is trying to access.
:param product.public.category category: The category the user is trying to access, if any.
:param dict kwargs: Optional data. This parameter is not used here.
:return: A redirect response to a valid shop or product page, or a 404 error code if no valid
fallback is found.
:rtype: int | Response
"""
product = product.exists()
if category:
category = category.exists()
if category and not (product and product.has_access('read')):
return request.redirect(WebsiteSale._get_shop_path(category))
if not category and product and product.has_access('read'):
return request.redirect(product._get_product_url())
return NotFound.code # 404
class TableCompute:
def __init__(self):
self.table = {}
def _check_place(self, posx, posy, sizex, sizey, ppr):
res = True
for y in range(sizey):
for x in range(sizex):
if posx + x >= ppr:
res = False
break
row = self.table.setdefault(posy + y, {})
if row.setdefault(posx + x) is not None:
res = False
break
for x in range(ppr):
self.table[posy + y].setdefault(x, None)
return res
def process(self, products, ppg=20, ppr=4):
# Compute products positions on the grid
minpos = 0
index = 0
maxy = 0
x = 0
for p in products:
x = min(max(p.website_size_x, 1), ppr)
y = min(max(p.website_size_y, 1), ppr)
if index >= ppg:
x = y = 1
pos = minpos
while not self._check_place(pos % ppr, pos // ppr, x, y, ppr):
pos += 1
# if 21st products (index 20) and the last line is full (ppr products in it), break
# (pos + 1.0) / ppr is the line where the product would be inserted
# maxy is the number of existing lines
# + 1.0 is because pos begins at 0, thus pos 20 is actually the 21st block
# and to force python to not round the division operation
if index >= ppg and ((pos + 1.0) // ppr) > maxy:
break
if x == 1 and y == 1: # simple heuristic for CPU optimization
minpos = pos // ppr
for y2 in range(y):
for x2 in range(x):
self.table[(pos // ppr) + y2][(pos % ppr) + x2] = False
self.table[pos // ppr][pos % ppr] = {
'product': p, 'x': x, 'y': y,
'ribbon': p.sudo().website_ribbon_id,
}
if index <= ppg:
maxy = max(maxy, y + (pos // ppr))
index += 1
# Format table according to HTML needs
rows = sorted(self.table.items())
rows = [r[1] for r in rows]
for col in range(len(rows)):
cols = sorted(rows[col].items())
x += len(cols)
rows[col] = [r[1] for r in cols if r[1]]
return rows
class WebsiteSale(payment_portal.PaymentPortal):
_express_checkout_route = '/shop/express_checkout'
_express_checkout_delivery_route = '/shop/express/shipping_address_change'
WRITABLE_PARTNER_FIELDS = [
'name',
'email',
'phone',
'street',
'street2',
'city',
'zip',
'country_id',
'state_id',
]
def _get_search_order(self, post):
# OrderBy will be parsed in orm and so no direct sql injection
# id is added to be sure that order is a unique sort key
order = post.get('order') or request.env['website'].get_current_website().shop_default_sort
return 'is_published desc, %s, id desc' % order
def _add_search_subdomains_hook(self, search):
return []
def _get_shop_domain(self, search, category, attribute_value_dict, search_in_description=True):
domains = [request.website.sale_product_domain()]
if search:
for srch in search.split(" "):
subdomains = [
Domain('name', 'ilike', srch),
Domain('variants_default_code', 'ilike', srch),
]
if search_in_description:
subdomains.extend((
Domain('website_description', 'ilike', srch),
Domain('description_sale', 'ilike', srch),
))
extra_subdomain = self._add_search_subdomains_hook(srch)
if extra_subdomain:
subdomains.append(extra_subdomain)
domains.append(Domain.OR(subdomains))
if category:
domains.append(Domain('public_categ_ids', 'child_of', int(category)))
if attribute_value_dict:
domains.extend(
request.env['product.template']._get_attribute_value_domain(attribute_value_dict)
)
return Domain.AND(domains)
def sitemap_shop(env, rule, qs):
website = env['website'].get_current_website()
if website and website.ecommerce_access == 'logged_in' and not qs:
# Make sure urls are not listed in sitemap when restriction is active
# and no autocomplete query string is provided
return
if not qs or qs.lower() in SHOP_PATH:
yield {'loc': SHOP_PATH}
Category = env['product.public.category']
dom = sitemap_qs2dom(qs, f'{SHOP_PATH}/category', Category._rec_name)
dom &= website.website_domain()
for cat in Category.search(dom):
loc = f'{SHOP_PATH}/category/{env["ir.http"]._slug(cat)}'
if not qs or qs.lower() in loc:
yield {'loc': loc}
def sitemap_products(env, rule, qs):
website = env['website'].get_current_website()
if website and website.ecommerce_access == 'logged_in' and not qs:
# Make sure urls are not listed in sitemap when restriction is active
# and no autocomplete query string is provided
return
ProductTemplate = env['product.template']
dom = sitemap_qs2dom(qs, SHOP_PATH, ProductTemplate._rec_name)
dom &= Domain(website.sale_product_domain())
for product in ProductTemplate.with_context(prefetch_fields=False).search(dom):
loc = f'{SHOP_PATH}/{env["ir.http"]._slug(product)}'
if not qs or qs.lower() in loc:
yield {'loc': loc}
def _get_search_options(
self,
category=None,
attribute_value_dict=None,
tags=None,
min_price=0.0,
max_price=0.0,
conversion_rate=1,
**post,
):
return {
'displayDescription': True,
'displayDetail': True,
'displayExtraDetail': True,
'displayExtraLink': True,
'displayImage': True,
'allowFuzzy': not post.get('noFuzzy'),
'category': str(category.id) if category else None,
'tags': tags,
'min_price': min_price / conversion_rate,
'max_price': max_price / conversion_rate,
'attribute_value_dict': attribute_value_dict,
'display_currency': post.get('display_currency'),
}
def _shop_lookup_products(self, options, post, search, website):
# No limit because attributes are obtained from complete product list
product_count, details, fuzzy_search_term = website._search_with_fuzzy("products_only", search,
limit=None,
order=self._get_search_order(post),
options=options)
search_result = details[0].get('results', request.env['product.template']).with_context(bin_size=True)
return fuzzy_search_term, product_count, search_result
def _shop_get_query_url_kwargs(
self, search, min_price, max_price, order=None, tags=None, **kwargs
):
attribute_values = request.session.get('attribute_values', [])
return {
'search': search,
'min_price': min_price,
'max_price': max_price,
'order': order,
'tags': tags,
'attribute_values': attribute_values,
}
def _get_additional_shop_values(self, values, **kwargs):
""" Hook to update values used for rendering website_sale.products template """
return {}
def _get_product_query_params(self, **kwargs):
"""Allow to configure the product page URL's query string."""
return {}
@route(
[
SHOP_PATH,
f'{SHOP_PATH}/page/<int:page>',
f'{SHOP_PATH}/category/<model("product.public.category"):category>',
f'{SHOP_PATH}/category/<model("product.public.category"):category>/page/<int:page>',
],
type='http',
auth='public',
website=True,
list_as_website_content=_lt("Shop"),
sitemap=sitemap_shop,
# Sends a 404 error in case of any Access error instead of 403.
handle_params_access_error=lambda e, **kwargs: NotFound.code,
)
def shop(self, page=0, category=None, search='', min_price=0.0, max_price=0.0, tags='', **post):
if not request.website.has_ecommerce_access():
return request.redirect(f'/web/login?redirect={request.httprequest.path}')
is_category_in_query = category and isinstance(category, str)
category = self._validate_and_get_category(category)
# If the category is provided as a query parameter (which is deprecated), we redirect to the
# "correct" shop URL, where the category has been removed from the query parameters and
# added to the path.
if is_category_in_query:
query = self._get_filtered_query_string(
request.httprequest.query_string.decode(), keys_to_remove=['category']
)
return request.redirect(f'{self._get_shop_path(category, page)}?{query}', code=301)
try:
min_price = float(min_price)
except ValueError:
min_price = 0
try:
max_price = float(max_price)
except ValueError:
max_price = 0
website = request.env['website'].get_current_website()
website_domain = website.website_domain()
ppg = website.shop_ppg or 21
ppr = website.shop_ppr or 4
gap = website.shop_gap or "16px"
request_args = request.httprequest.args
attribute_values = request_args.getlist('attribute_values')
attribute_value_dict = self._get_attribute_value_dict(attribute_values)
attribute_ids = set(attribute_value_dict.keys())
attribute_value_ids = set(itertools.chain.from_iterable(attribute_value_dict.values()))
if attribute_values:
request.session['attribute_values'] = attribute_values
post['attribute_values'] = attribute_values
else:
request.session.pop('attribute_values', None)
filter_by_tags_enabled = website.is_view_active('website_sale.filter_products_tags')
if filter_by_tags_enabled:
if tags:
post['tags'] = tags
tags = {self.env['ir.http']._unslug(tag)[1] for tag in tags.split(',')}
else:
post['tags'] = None
tags = {}
url = self._get_shop_path(category)
keep = QueryURL(
url, **self._shop_get_query_url_kwargs(search, min_price, max_price, **post)
)
# Check if we need to refresh the cached pricelist
now = datetime.timestamp(datetime.now())
if 'website_sale_pricelist_time' in request.session:
pricelist_save_time = request.session['website_sale_pricelist_time']
if pricelist_save_time < now - 60*60:
request.session.pop(PRICELIST_SESSION_CACHE_KEY, None)
# restart the counter
request.session['website_sale_pricelist_time'] = now
filter_by_price_enabled = website.is_view_active('website_sale.filter_products_price')
if filter_by_price_enabled:
company_currency = website.company_id.sudo().currency_id
conversion_rate = request.env['res.currency']._get_conversion_rate(
company_currency, website.currency_id, request.website.company_id, fields.Date.today())
else:
conversion_rate = 1
if search:
post['search'] = search
options = self._get_search_options(
category=category,
attribute_value_dict=attribute_value_dict,
min_price=min_price,
max_price=max_price,
conversion_rate=conversion_rate,
display_currency=website.currency_id,
**post
)
fuzzy_search_term, product_count, search_product = self._shop_lookup_products(
options, post, search, website
)
filter_by_price_enabled = website.is_view_active('website_sale.filter_products_price')
if filter_by_price_enabled:
# TODO Find an alternative way to obtain the domain through the search metadata.
Product = request.env['product.template'].with_context(bin_size=True)
search_term = fuzzy_search_term if fuzzy_search_term else search
domain = self._get_shop_domain(search_term, category, attribute_value_dict)
# This is ~4 times more efficient than a search for the cheapest and most expensive products
query = Product._search(domain)
sql = query.select(
SQL(
"COALESCE(MIN(list_price), 0) * %(conversion_rate)s, COALESCE(MAX(list_price), 0) * %(conversion_rate)s",
conversion_rate=conversion_rate,
)
)
available_min_price, available_max_price = request.env.execute_query(sql)[0]
if min_price or max_price:
# The if/else condition in the min_price / max_price value assignment
# tackles the case where we switch to a list of products with different
# available min / max prices than the ones set in the previous page.
# In order to have logical results and not yield empty product lists, the
# price filter is set to their respective available prices when the specified
# min exceeds the max, and / or the specified max is lower than the available min.
if min_price:
min_price = min_price if min_price <= available_max_price else available_min_price
post['min_price'] = min_price
if max_price:
max_price = max_price if max_price >= available_min_price else available_max_price
post['max_price'] = max_price
ProductTag = request.env['product.tag']
if filter_by_tags_enabled and search_product:
all_tags = ProductTag.search_fetch(Domain.AND([
Domain('visible_to_customers', '=', True),
Domain.OR([
Domain('product_template_ids.is_published', '=', True),
Domain('product_ids.is_published', '=', True),
]),
website_domain,
]))
else:
all_tags = ProductTag
# categories
Category = request.env['product.public.category']
categs_domain = Domain('parent_id', '=', False) & website_domain
if not self.env.user._is_internal():
categs_domain &= Domain('has_published_products', '=', True)
if search:
search_categories = Category.search(
Domain('product_tmpl_ids', 'in', search_product.ids) & website_domain
).parents_and_self
categs_domain &= Domain('id', 'in', search_categories.ids)
else:
search_categories = Category
categs = Category.search_fetch(categs_domain)
category_entries = Category
if category:
category_entries = not search and category.child_id or category.child_id.filtered(lambda c: c.id in search_categories.ids)
if not category_entries:
parent = category.parent_id
category_entries = not search and parent.child_id or parent.child_id.filtered(lambda c: c.id in search_categories.ids)
else:
category_entries = categs
if not request.env.user._is_internal():
category_entries = category_entries.filtered('has_published_products')
# products for current pager
pager = website.pager(url=url, total=product_count, page=page, step=ppg, scope=5, url_args=post)
offset = pager['offset']
products = search_product[offset:offset + ppg]
products.fetch()
# map each product to its variant, and prefetch the variants
variants = request.env['product.product'].sudo().browse(product._get_first_possible_variant_id() for product in products)
variants.fetch()
product_variants = dict(zip(products, variants))
ProductAttribute = request.env['product.attribute']
if products:
# get all products without limit
attributes_grouped = request.env['product.template.attribute.line']._read_group(
domain=[
('product_tmpl_id', 'in', search_product.ids),
('attribute_id.visibility', '=', 'visible'),
],
groupby=['attribute_id'],
order='attribute_id'
)
attribute_ids = [attribute.id for attribute, in attributes_grouped]
attributes = ProductAttribute.browse(attribute_ids)
else:
attributes = ProductAttribute.browse(attribute_ids).sorted()
if website.is_view_active('website_sale.products_list_view'):
layout_mode = 'list'
else:
layout_mode = 'grid'
products_prices = products._get_sales_prices(website)
product_query_params = self._get_product_query_params(**post)
grouped_attributes_values = request.env['product.attribute.value'].browse(
attribute_value_ids
).sorted().grouped('attribute_id')
values = {
'auto_assign_ribbons': self.env['product.ribbon'].sudo().search([('assign', '!=', 'manual')]),
'search': fuzzy_search_term or search,
'original_search': fuzzy_search_term and search,
'order': post.get('order', ''),
'category': category,
'attrib_values': attribute_value_dict,
'attrib_set': attribute_value_ids,
'pager': pager,
'products': products,
'product_variants': product_variants,
'search_product': search_product,
'search_count': product_count, # common for all searchbox
'bins': TableCompute().process(products, ppg, ppr),
'ppg': ppg,
'ppr': ppr,
'gap': gap,
'categories': categs,
'category_entries': category_entries,
'attributes': attributes,
'keep': keep,
'search_categories_ids': search_categories.ids,
'layout_mode': layout_mode,
'get_product_prices': lambda product: products_prices[product.id],
'float_round': float_round,
'shop_path': SHOP_PATH,
'product_query_params': product_query_params,
'grouped_attributes_values': grouped_attributes_values,
'previewed_attribute_values': lazy(
lambda: products._get_previewed_attribute_values(category, product_query_params),
),
}
if filter_by_price_enabled:
values['min_price'] = min_price or available_min_price
values['max_price'] = max_price or available_max_price
values['available_min_price'] = float_round(available_min_price, 2)
values['available_max_price'] = float_round(available_max_price, 2)
if filter_by_tags_enabled:
values.update({'all_tags': all_tags, 'tags': tags})
if category:
values['main_object'] = category
values.update(self._get_additional_shop_values(values, **post))
return request.render("website_sale.products", values)
@route(
[
f'{SHOP_PATH}/<model("product.template"):product>',
f'{SHOP_PATH}/<model("product.public.category"):category>/<model("product.template"):product>',
],
type='http',
auth='public',
website=True,
sitemap=sitemap_products,
handle_params_access_error=handle_product_params_error,
)
def product(self, product, category=None, pricelist=None, **kwargs):
if not request.website.has_ecommerce_access():
return request.redirect(f'/web/login?redirect={request.httprequest.path}')
if pricelist is not None:
try:
pricelist_id = int(pricelist)
except ValueError:
raise ValidationError(request.env._(
"Wrong format: got `pricelist=%s`, expected an integer", pricelist,
))
if not self._apply_selectable_pricelist(pricelist_id):
return request.redirect(self._get_shop_path(category))
is_category_in_query = category and isinstance(category, str)
category = self._validate_and_get_category(category)
query = self._get_filtered_query_string(
request.httprequest.query_string.decode(), keys_to_remove=['category']
)
# If the product doesn't belong to the category, we redirect to the canonical product URL,
# which doesn't include the category.
if (
category
and not product.filtered_domain([('public_categ_ids', 'child_of', category.id)])
):
return request.redirect(f'{product._get_product_url()}?{query}', code=301)
# If the category is provided as a query parameter (which is deprecated), we redirect to the
# "correct" shop URL, where the category has been removed from the query parameters and
# added to the path.
if is_category_in_query:
return request.redirect(
f'{product._get_product_url(category)}?{query}', code=301
)
return request.render(
'website_sale.product',
self._prepare_product_values(
# request context must be given to ensure context updates in overrides are correctly
# forwarded to `_get_combination_info` call
product.with_context(request.env.context), category, **kwargs,
)
)
@route(
'/shop/<model("product.template"):product_template>/document/<int:document_id>',
type='http',
auth='public',
website=True,
sitemap=False,
readonly=True,
)
def product_document(self, product_template, document_id):
product_template.check_access('read')
document = request.env['product.document'].browse(document_id).sudo().exists()
if not document or not document.active:
return request.redirect(self._get_shop_path())
if not document.shown_on_product_page or not (
document.res_id == product_template.id
and document.res_model == 'product.template'
):
return request.redirect(self._get_shop_path())
return request.env['ir.binary']._get_stream_from(
document.ir_attachment_id,
).get_response(as_attachment=True)
@route(
[f'{SHOP_PATH}/product/<model("product.template"):product>'],
type='http',
auth='public',
website=True,
sitemap=False,
)
def old_product(self, product, category='', **kwargs):
# Compatibility pre-v14
# Redirect to the "correct" product URL, which doesn't include `/product`, and where the
# category has been removed from the query parameters and added to the path.
category = int(category) if str(category).isdigit() else False
category = self._validate_and_get_category(category)
query = self._get_filtered_query_string(
request.httprequest.query_string.decode(), keys_to_remove=['category']
)
return request.redirect(f'{product._get_product_url(category)}?{query}', code=301)
@route(['/shop/product/extra-media'], type='jsonrpc', auth='user', website=True)
def add_product_media(self, media, type, product_product_id, product_template_id, combination_ids=None):
"""
Handles adding both images and videos to product variants or templates,
links all of them to product.
:param type: [...] can be either image or video
:raises NotFound : If the user is not allowed to access Attachment model
"""
if not request.env.user.has_group('website.group_website_restricted_editor'):
raise NotFound()
if type == 'image': # Image case
image_ids = request.env["ir.attachment"].browse(i['id'] for i in media)
media_create_data = [Command.create({
'name': image.name, # Images uploaded from url do not have any datas. This recovers them manually
'image_1920': image.datas
if image.datas
else request.env['ir.qweb.field.image'].load_remote_url(image.url),
}) for image in image_ids]
elif type == 'video': # Video case
video_data = media[0]
thumbnail = None
if video_data.get('src'): # Check if a valid video URL is provided
try:
thumbnail = base64.b64encode(get_video_thumbnail(video_data['src']))
except Exception:
thumbnail = None
else:
raise ValidationError(_("Invalid video URL provided."))
media_create_data = [Command.create({
'name': video_data.get('name', 'Odoo Video'),
'video_url': video_data['src'],
'image_1920': thumbnail,
})]
product_product = request.env['product.product'].browse(int(product_product_id)) if product_product_id else False
product_template = request.env['product.template'].browse(int(product_template_id)) if product_template_id else False
if product_product and not product_template:
product_template = product_product.product_tmpl_id
if not product_product and product_template and product_template.has_dynamic_attributes():
combination = request.env['product.template.attribute.value'].browse(combination_ids)
product_product = product_template._get_variant_for_combination(combination)
if not product_product:
product_product = product_template._create_product_variant(combination)
if product_template.has_configurable_attributes and product_product and not all(pa.create_variant == 'no_variant' for pa in product_template.attribute_line_ids.attribute_id):
product_product.write({
'product_variant_image_ids': media_create_data
})
else:
product_template.write({
'product_template_image_ids': media_create_data
})
@route(['/shop/product/clear-images'], type='jsonrpc', auth='user', website=True)
def clear_product_images(self, product_product_id, product_template_id):
"""
Unlinks all images from the product.
"""
if not request.env.user.has_group('website.group_website_restricted_editor'):
raise NotFound()
product_product = request.env['product.product'].browse(int(product_product_id)) if product_product_id else False
product_template = request.env['product.template'].browse(int(product_template_id)) if product_template_id else False
if product_product and not product_template:
product_template = product_product.product_tmpl_id
if product_product and product_product.product_variant_image_ids:
product_product.product_variant_image_ids.unlink()
else:
product_template.product_template_image_ids.unlink()
@route(['/shop/product/resequence-image'], type='jsonrpc', auth='user', website=True)
def resequence_product_image(self, image_res_model, image_res_id, move):
"""
Move the product image in the given direction and update all images' sequence.
:param str image_res_model: The model of the image. It can be 'product.template',
'product.product', or 'product.image'.
:param str image_res_id: The record ID of the image to move.
:param str move: The direction of the move. It can be 'first', 'left', 'right', or 'last'.
:raises NotFound: If the user does not have the required permissions, if the model of the
image is not allowed, or if the move direction is not allowed.
:raise ValidationError: If the product is not found.
:raise ValidationError: If the image to move is not found in the product images.
:raise ValidationError: If a video is moved to the first position.
:return: None
"""
if (
not request.env.user.has_group('website.group_website_restricted_editor')
or image_res_model not in ['product.product', 'product.template', 'product.image']
or move not in ['first', 'left', 'right', 'last']
):
raise NotFound()
image_res_id = int(image_res_id)
image_to_resequence = request.env[image_res_model].browse(image_res_id)
if image_res_model == 'product.product':
product = image_to_resequence
product_template = product.product_tmpl_id
elif image_res_model == 'product.template':
product_template = image_to_resequence
product = product_template.product_variant_id
else:
product = image_to_resequence.product_variant_id
product_template = product.product_tmpl_id or image_to_resequence.product_tmpl_id
if not product and not product_template:
raise ValidationError(_("Product not found"))
product_images = (product or product_template)._get_images()
if image_to_resequence not in product_images:
raise ValidationError(_("Invalid image"))
image_idx = product_images.index(image_to_resequence)
new_image_idx = 0
if move == 'left':
new_image_idx = max(0, image_idx - 1)
elif move == 'right':
new_image_idx = min(len(product_images) - 1, image_idx + 1)
elif move == 'last':
new_image_idx = len(product_images) - 1
# no-op resequences
if new_image_idx == image_idx:
return
# Reorder images locally.
product_images.insert(new_image_idx, product_images.pop(image_idx))
# If the main image has been reordered (i.e. it's no longer in first position), use the
# image that's now in first position as main image instead.
# Additional images are product.image records. The main image is a product.product or
# product.template record.
main_image_idx = next(
idx for idx, image in enumerate(product_images) if image._name != 'product.image'
)
if main_image_idx != 0:
main_image = product_images[main_image_idx]
additional_image = product_images[0]
if additional_image.video_url:
raise ValidationError(_("You can't use a video as the product's main image."))
# Swap records.
product_images[main_image_idx], product_images[0] = additional_image, main_image
# Swap image data.
main_image.image_1920, additional_image.image_1920 = (
additional_image.image_1920, main_image.image_1920
)
additional_image.name = main_image.name # Update image name but not product name.
# Resequence additional images according to the new ordering.
for idx, product_image in enumerate(product_images):
if product_image._name == 'product.image':
product_image.sequence = idx
@route(['/shop/product/is_add_to_cart_allowed'], type='jsonrpc', auth="public", website=True, readonly=True)
def is_add_to_cart_allowed(self, product_id, **kwargs):
product = request.env['product.product'].browse(product_id)
# In sudo mode to check fields and conditions not accessible to the customer directly.
return product.sudo()._is_add_to_cart_allowed()
def _prepare_product_values(self, product, category, **kwargs):
ProductCategory = request.env['product.public.category']
product_markup_data = [product._to_markup_data(request.website)]
category = (
(category and ProductCategory.browse(int(category)).exists())
or product.public_categ_ids[:1]
)
if category:
# Add breadcrumb's SEO data.
product_markup_data.append(self._prepare_breadcrumb_markup_data(
request.website.get_base_url(), category, product.name
))
if (last_attributes_search := request.session.get('attribute_values', [])):
keep = QueryURL(
self._get_shop_path(category),
attribute_values=last_attributes_search
)
else:
keep = QueryURL(self._get_shop_path(category))
if attribute_values := kwargs.get('attribute_values', ''):
attribute_value_ids = {int(i) for i in attribute_values.split(',')}
combination = product.attribute_line_ids.mapped(
lambda ptal: (
ptal.product_template_value_ids.filtered(
lambda ptav: (
ptav.ptav_active
and ptav.product_attribute_value_id.id in attribute_value_ids
)
)[:1]
) or ptal.product_template_value_ids.filtered('ptav_active')[:1]
)
combination_info = product._get_combination_info(
combination=request.env['product.template.attribute.value'].concat(combination)
)
else:
combination_info = product._get_combination_info()
# Needed to trigger the recently viewed product rpc
view_track = request.website.viewref("website_sale.product").track
return {
'categories': ProductCategory.search([('parent_id', '=', False)]),
'category': category,
'combination_info': combination_info,
'keep': keep,
'main_object': product,
'product': product,
'product_variant': request.env['product.product'].browse(combination_info['product_id']),
'view_track': view_track,
'product_markup_data': json_scriptsafe.dumps(product_markup_data, indent=2),
'shop_path': SHOP_PATH,
}
def _prepare_breadcrumb_markup_data(self, base_url, category, product_name):
""" Generate JSON-LD markup data for the given product category.
See https://schema.org/BreadcrumbList.
:param str base_url: The base URL of the current website.
:param product.public.category category: The current product category.
:param str product_name: The name of the current product.
:return: The JSON-LD markup data.
:rtype: dict
"""
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
'itemListElement': [
{
'@type': 'ListItem',
'position': 1,
'name': 'All Products',
'item': f'{base_url}{self._get_shop_path()}',
},
{
'@type': 'ListItem',
'position': 2,
'name': category.name,
'item': f'{base_url}{self._get_shop_path(category)}',
},
{
'@type': 'ListItem',
'position': 3,
'name': product_name,
}
]
}
@route(
'/shop/change_pricelist/<model("product.pricelist"):pricelist>',
type='http',
auth='public',
website=True,
sitemap=False,
)
def pricelist_change(self, pricelist, **post):
website = request.env['website'].get_current_website()
redirect_url = request.httprequest.referrer
prev_pricelist = request.pricelist
if (
self._apply_selectable_pricelist(pricelist.id)
and redirect_url
and website.is_view_active('website_sale.filter_products_price')
and prev_pricelist != pricelist
):
# Convert prices to the new priceslist currency in the query params of the referrer
decoded_url = url_parse(redirect_url)
args = url_decode(decoded_url.query)
min_price = args.get('min_price')
max_price = args.get('max_price')
if min_price or max_price:
try:
min_price = float(min_price)
args['min_price'] = min_price and str(prev_pricelist.currency_id._convert(
min_price,
pricelist.currency_id,
request.website.company_id,
fields.Date.today(),
round=False,
))
except (ValueError, TypeError):
pass
try:
max_price = float(max_price)
args['max_price'] = max_price and str(prev_pricelist.currency_id._convert(
max_price,
pricelist.currency_id,
request.website.company_id,
fields.Date.today(),
round=False,
))
except (ValueError, TypeError):
pass
redirect_url = decoded_url.replace(query=url_encode(args)).to_url()
return request.redirect(redirect_url or self._get_shop_path())
@route('/shop/pricelist', type='http', auth='public', website=True, sitemap=False)
def pricelist(self, promo, **post):
redirect = post.get('r', '/shop/cart')
if promo:
pricelist_sudo = request.env['product.pricelist'].sudo().search([('code', '=', promo)], limit=1)
if not (pricelist_sudo and request.website.is_pricelist_available(pricelist_sudo.id)):
return request.redirect("%s?code_not_available=1" % redirect)
self._apply_pricelist(pricelist=pricelist_sudo)
else:
# Reset the pricelist if empty promo code is given
self._apply_pricelist(pricelist=None)
return request.redirect(redirect)
def _apply_selectable_pricelist(self, pricelist_id):
""" Change the request pricelist if selectable on the website.
A pricelist is applied if:
- it is available on the current website
- it is selectable or on the current partner
:param int pricelist_id: the pricelist ID
:return: True or False if the pricelist was applied or not
:rtype: bool
"""
if (
request.env['website'].get_current_website().is_pricelist_available(pricelist_id)
and (pricelist := request.env['product.pricelist'].browse(pricelist_id))
and (
pricelist.selectable
or pricelist == request.env.user.partner_id.property_product_pricelist
)
):
self._apply_pricelist(pricelist=pricelist)
return True
return False
def _apply_pricelist(self, pricelist=None):
""" Changes the pricelist of the request and recomputes the current cart prices.
:param 'product.pricelist'|None pricelist: The new pricelist. If None resets the pricelist.
"""
if pricelist is None: # Reset the pricelist
request.session.pop(PRICELIST_SESSION_CACHE_KEY, None)
request.session.pop(PRICELIST_SELECTED_SESSION_CACHE_KEY, None)
request.pricelist = lazy(request.website._get_and_cache_current_pricelist)
if order_sudo := request.cart:
pl_before = order_sudo.pricelist_id
order_sudo._compute_pricelist_id()
if order_sudo.pricelist_id != pl_before:
order_sudo._recompute_prices()
return
pricelist.ensure_one()
if pricelist.id == request.pricelist.id:
# Nothing to do
return
request.session[PRICELIST_SESSION_CACHE_KEY] = pricelist.id
request.session[PRICELIST_SELECTED_SESSION_CACHE_KEY] = pricelist.id
request.pricelist = pricelist.sudo()
if order_sudo := request.cart:
order_sudo.pricelist_id = pricelist
order_sudo._recompute_prices()
@route('/shop/save_shop_layout_mode', type='jsonrpc', auth='public', website=True)
def save_shop_layout_mode(self, layout_mode):
assert layout_mode in ('grid', 'list'), "Invalid shop layout mode"
request.session['website_sale_shop_layout_mode'] = layout_mode
# ------------------------------------------------------
# Checkout
# ------------------------------------------------------
# === CHECKOUT FLOW - ADDRESS METHODS === #
@route(
'/shop/checkout', type='http', methods=['GET'], auth='public', website=True, sitemap=False, list_as_website_content=_lt("Shop Checkout")
)
def shop_checkout(self, try_skip_step=None, **query_params):
""" Display the checkout page.
:param str try_skip_step: Whether the user should immediately be redirected to the next step
if no additional information (i.e., address or delivery method) is
required on the checkout page. 'true' or 'false'.
:param dict query_params: The additional query string parameters.
:return: The rendered checkout page.
:rtype: str
"""
try_skip_step = str2bool(try_skip_step or 'false')
order_sudo = request.cart
request.session['sale_last_order_id'] = order_sudo.id
if redirection := self._check_cart_and_addresses(order_sudo):
return redirection
checkout_page_values = self._prepare_checkout_page_values(order_sudo, **query_params)
can_skip_delivery = True # Delivery is only needed for deliverable products.
if order_sudo._has_deliverable_products():
can_skip_delivery = False
available_dms = order_sudo._get_delivery_methods()
checkout_page_values['delivery_methods'] = available_dms
if delivery_method := order_sudo._get_preferred_delivery_method(available_dms):
rate = delivery_method.rate_shipment(order_sudo)
if (
not order_sudo.carrier_id
or not rate.get('success')
or order_sudo.amount_delivery != rate['price']
):
order_sudo._set_delivery_method(delivery_method, rate=rate)
checkout_page_values.update(
request.website._get_checkout_step_values()
)
if try_skip_step and can_skip_delivery:
return request.redirect(
checkout_page_values['next_website_checkout_step_href']
)
return request.render('website_sale.checkout', checkout_page_values)
def _prepare_checkout_page_values(self, order_sudo, **kwargs):
"""Provide the data used to render the /shop/checkout page.
:param sale.order order_sudo: The current cart.
:param dict kwargs: unused parameters available for potential overrides.
:return: The checkout page rendering values.
:rtype: dict
"""
partner_sudo = order_sudo.partner_id
return {
'order': order_sudo,
'website_sale_order': order_sudo, # Compatibility with other templates.
'use_delivery_as_billing': (
order_sudo.partner_shipping_id == order_sudo.partner_invoice_id
),
'only_services': order_sudo.only_services,
**self._prepare_address_data(partner_sudo, **kwargs),
'address_url': '/shop/address',
}
@route(
'/shop/address', type='http', methods=['GET'], auth='public', website=True, sitemap=False
)
def shop_address(
self, partner_id=None, address_type='billing', use_delivery_as_billing=None, **query_params
):
""" Display the address form.
A partner and/or an address type can be given through the query string params to specify
which address to update or create, and its type.
:param str partner_id: The partner whose address to update with the address form, if any.
:param str address_type: The type of the address: 'billing' or 'delivery'.
:param str use_delivery_as_billing: Whether the provided address should be used as both the
delivery and the billing address. 'true' or 'false'.
:param dict query_params: The additional query string parameters forwarded to
`_prepare_address_form_values`.
:return: The rendered address form.
:rtype: str
"""
use_delivery_as_billing = str2bool(use_delivery_as_billing or 'false')
order_sudo = request.cart
if redirection := self._check_cart(order_sudo):
return redirection
# Retrieve the partner whose address to update, if any, and its address type.
partner_sudo, address_type = self._prepare_address_update(
order_sudo, partner_id=partner_id and int(partner_id), address_type=address_type
)
use_delivery_as_billing = str2bool(use_delivery_as_billing or 'false')
if partner_sudo: # If editing an existing partner.
use_delivery_as_billing = (
partner_sudo == order_sudo.partner_shipping_id == order_sudo.partner_invoice_id
)
# Render the address form.
address_form_values = self._prepare_address_form_values(
partner_sudo,
address_type=address_type,
order_sudo=order_sudo,
use_delivery_as_billing=use_delivery_as_billing,
**query_params
)
address_form_values.update(
request.website._get_checkout_step_values()
)
return request.render('website_sale.address', address_form_values)
def _prepare_address_form_values(
self,
*args,
callback='',
order_sudo=False,
**kwargs
):
"""Prepare the rendering values of the address form.
:param str callback: The URL to redirect to in case of successful address creation/update.
:param sale.order order_sudo: The current cart.
:return: The checkout page values.
:rtype: dict
"""
rendering_values = super()._prepare_address_form_values(
*args, order_sudo=order_sudo, callback=callback, **kwargs
)
if not order_sudo: # Return portal address values if not order
return rendering_values
is_anonymous_cart = order_sudo._is_anonymous_cart()
# Display b2b field is feature is enabled on given website
rendering_values['display_b2b_fields'] = (
rendering_values.get('display_b2b_fields', False)
or request.website.is_view_active('website_sale.address_b2b')
)
if rendering_values['commercial_address_update_url']:
rendering_values['commercial_address_update_url'] = f'/shop/address?partner_id={order_sudo.partner_id.id}'
return {
**rendering_values,
'is_anonymous_cart': is_anonymous_cart,
'website_sale_order': order_sudo,
'only_services': order_sudo.only_services,
'discard_url': callback or (is_anonymous_cart and '/shop/cart') or '/shop/checkout',
}
def _get_default_country(self, order_sudo=False, **kwargs):
""" Override `portal` to return country of customer if customer is not logged in."""
is_anonymous_cart = order_sudo and order_sudo._is_anonymous_cart()
if is_anonymous_cart and request.geoip.country_code:
return request.env['res.country'].sudo().search([
('code', '=', request.geoip.country_code),
], limit=1)
return super()._get_default_country(order_sudo=order_sudo, **kwargs)
@route(
'/shop/address/submit', type='http', methods=['POST'], auth='public', website=True,
sitemap=False
)
def shop_address_submit(
self,
partner_id=None,
address_type='billing',
use_delivery_as_billing=None,
callback=None,
**form_data
):
""" Create or update an address.
If it succeeds, it returns the URL to redirect (client-side) to. If it fails (missing or
invalid information), it highlights the problematic form input with the appropriate error
message.
:param str partner_id: The partner whose address to update with the address form, if any.
:param str address_type: The type of the address: 'billing' or 'delivery'.
:param str use_delivery_as_billing: Whether the provided address should be used as both the
billing and the delivery address. 'true' or 'false'.
:param str callback: The URL to redirect to in case of successful address creation/update.
:param dict form_data: The form data to process as address values.
:return: A JSON-encoded feedback, with either the success URL or an error message.
:rtype: str
"""
order_sudo = request.cart
if redirection := self._check_cart(order_sudo):
return json.dumps({'redirectUrl': redirection.location})
# Retrieve the partner whose address to update, if any, and its address type.
partner_sudo, address_type = self._prepare_address_update(
order_sudo, partner_id=partner_id and int(partner_id), address_type=address_type
)
is_new_address = not partner_sudo
if is_new_address or order_sudo.only_services:
callback = callback or '/shop/checkout?try_skip_step=true'
else:
callback = callback or '/shop/checkout'
partner_sudo, feedback_dict = self._create_or_update_address(
partner_sudo,
address_type=address_type,
use_delivery_as_billing=use_delivery_as_billing,
callback=callback,
order_sudo=order_sudo,
**form_data
)
if feedback_dict.get('invalid_fields'):
return json.dumps(feedback_dict) # Return if error when creating/updating partner.
is_anonymous_cart = order_sudo._is_anonymous_cart()
is_main_address = is_anonymous_cart or order_sudo.partner_id.id == partner_sudo.id
partner_fnames = set()
if is_main_address: # Main customer address updated.
partner_fnames.add('partner_id') # Force the re-computation of partner-based fields.
if address_type == 'billing':
partner_fnames.add('partner_invoice_id')
if is_new_address and order_sudo.only_services:
# The delivery address is required to make the order.
partner_fnames.add('partner_shipping_id')
elif address_type == 'delivery':
partner_fnames.add('partner_shipping_id')
if use_delivery_as_billing:
partner_fnames.add('partner_invoice_id')
order_sudo._update_address(partner_sudo.id, partner_fnames)
if order_sudo._is_anonymous_cart():
# Unsubscribe the public partner if the cart was previously anonymous.
order_sudo.message_unsubscribe(order_sudo.website_id.partner_id.ids)
return json.dumps(feedback_dict)
def _needs_address(self):
if cart := request.cart:
return cart._needs_customer_address()
return super()._needs_address()
def _prepare_address_update(self, order_sudo, partner_id=None, address_type=None):
""" Find the partner whose address to update and return it along with its address type.
:param sale.order order_sudo: The current cart.
:param int partner_id: The partner whose address to update, if any, as a `res.partner` id.
:param str address_type: The type of the address: 'billing' or 'delivery'.
:return: The partner whose address to update, if any, and its address type.
:rtype: tuple[res.partner, str]
:raise Forbidden: If the customer is not allowed to update the given address.
"""
PartnerSudo = request.env['res.partner'].with_context(show_address=1).sudo()
if order_sudo._is_anonymous_cart():
partner_sudo = PartnerSudo
else:
partner_sudo = PartnerSudo.browse(partner_id)
if partner_sudo and partner_sudo not in {
order_sudo.partner_id,
order_sudo.partner_invoice_id,
order_sudo.partner_shipping_id,
}: # The partner is not yet linked to the SO.
partner_sudo = partner_sudo.exists()
if partner_sudo and not address_type: # The desired address type was not specified.
# Identify the address type based on the cart's billing and delivery partners.
if partner_id == order_sudo.partner_invoice_id.id:
address_type = 'billing'
elif partner_id == order_sudo.partner_shipping_id.id:
address_type = 'delivery'
else:
address_type = 'billing'
if (
partner_sudo
and not partner_sudo._can_be_edited_by_current_customer(order_sudo=order_sudo)
):
raise Forbidden()
return partner_sudo, address_type
def _complete_address_values(
self, address_values, *args, order_sudo=False, **kwargs
):
super()._complete_address_values(
address_values, *args, order_sudo=order_sudo, **kwargs
)
if order_sudo and order_sudo._is_anonymous_cart():
address_values['type'] = 'contact'
if address_values['lang'] not in request.website.mapped('language_ids.code'):
address_values.pop('lang')
if not order_sudo:
return
address_values['company_id'] = (
order_sudo.website_id.company_id.id
or address_values['company_id']
)
address_values['user_id'] = order_sudo.website_id.salesperson_id.id
if order_sudo.website_id.specific_user_account:
address_values['website_id'] = order_sudo.website_id.id
def _create_new_address(
self, address_values, address_type, use_delivery_as_billing, order_sudo
):
""" Create a new partner, must be called after the data has been verified
NB: to verify (and preprocess) the data, please call `_parse_form_data` first.
:param order_sudo: the current cart, as a sudoed `sale.order` recordset
:param str address_type: 'billing' or 'delivery'
:param bool use_delivery_as_billing: Whether the address must be used as the billing and the
delivery address.
:param dict address_values: values to use to create the partner
:return: The created address, as a sudoed `res.partner` recordset.
"""
self._complete_address_values(
address_values, address_type, use_delivery_as_billing, order_sudo=order_sudo
)
creation_context = clean_context(request.env.context)
creation_context.update({
'tracking_disable': True,
# 'no_vat_validation': True, # TODO VCR VAT validation or not ?
})
return request.env['res.partner'].sudo().with_context(
creation_context
).create(address_values)
@route(
_express_checkout_route, type='jsonrpc', methods=['POST'], auth="public", website=True,
sitemap=False
)
def process_express_checkout(
self, billing_address, shipping_address=None, shipping_option=None, **kwargs
):
""" Records the partner information on the order when using express checkout flow.
Depending on whether the partner is registered and logged in, either creates a new partner
or uses an existing one that matches all received data.
:param dict billing_address: Billing information sent by the express payment form.
:param dict shipping_address: Shipping information sent by the express payment form.
:param dict shipping_option: Carrier information sent by the express payment form.
:param dict kwargs: Optional data. This parameter is not used here.
:return int: The order's partner id.
"""
order_sudo = request.cart
# Update the partner with all the information
self._include_country_and_state_in_address(billing_address)
billing_address, _side_values = self._parse_form_data(billing_address)
if order_sudo._is_anonymous_cart():
# Pricelist are recomputed every time the partner is changed. We don't want to recompute
# the price with another pricelist at this state since the customer has already accepted
# the amount and validated the payment.
new_partner_sudo = self._create_new_address(
billing_address,
address_type='billing',
use_delivery_as_billing=False,
order_sudo=order_sudo,
)
with request.env.protecting([order_sudo._fields['pricelist_id']], order_sudo):
order_sudo.partner_id = new_partner_sudo
elif not self._are_same_addresses(billing_address, order_sudo.partner_invoice_id):
# Check if a child partner doesn't already exist with the same informations. The
# phone isn't always checked because it isn't sent in shipping information with
# Google Pay.
child_partner_id = self._find_child_partner(
order_sudo.partner_id.commercial_partner_id.id, billing_address
)
order_sudo.partner_invoice_id = child_partner_id or self._create_new_address(
billing_address,
address_type='billing',
use_delivery_as_billing=False,
order_sudo=order_sudo,
)
# In a non-express flow, `sale_last_order_id` would be added in the session before the
# payment. As we skip all the steps with the express checkout, `sale_last_order_id` must be
# assigned to ensure the right behavior from `shop_payment_confirmation()`.
request.session['sale_last_order_id'] = order_sudo.id
if shipping_address:
#in order to not override shippig address, it's checked separately from shipping option
self._include_country_and_state_in_address(shipping_address)
shipping_address, _side_values = self._parse_form_data(shipping_address)
if order_sudo.name in order_sudo.partner_shipping_id.name:
# The existing partner was created by `process_express_checkout_delivery_choice`, it
# means that the partner is missing information, so we update it.
order_sudo.partner_shipping_id.write(shipping_address)
order_sudo._update_address(
order_sudo.partner_shipping_id.id, ['partner_shipping_id']
)
elif not self._are_same_addresses(shipping_address, order_sudo.partner_shipping_id):
# The sale order's shipping partner's address is different from the one received. If
# all the sale order's child partners' address differs from the one received, we
# create a new partner. The phone isn't always checked because it isn't sent in
# shipping information with Google Pay.
child_partner_id = self._find_child_partner(
order_sudo.partner_id.commercial_partner_id.id, shipping_address
)
order_sudo.partner_shipping_id = child_partner_id or self._create_new_address(
shipping_address,
address_type='delivery',
use_delivery_as_billing=False,
order_sudo=order_sudo,
)
# Process the delivery method.
if shipping_option:
dm_id = int(shipping_option['id'])
available_dms = order_sudo._get_delivery_methods()
order_sudo._set_delivery_method(available_dms.filtered(lambda dm: dm.id == dm_id))
return order_sudo.partner_id.id
def _find_child_partner(self, commercial_partner_id, address):
""" Find a child partner for a specified address
Compare all keys in the `address` dict with the same keys on the partner object and return
the id of the first partner that have the same value than in the dict for all the keys.
:param int commercial_partner_id: The commercial partner whose child to find.
:param dict address: The address fields.
:return: The ID of the first child partner that match the criteria, if any.
:rtype: int
"""
partners_sudo = request.env['res.partner'].with_context(show_address=1).sudo().search([
('id', 'child_of', commercial_partner_id),
])
for partner_sudo in partners_sudo:
if self._are_same_addresses(address, partner_sudo):
return partner_sudo.id
return False
def _include_country_and_state_in_address(self, address):
""" This function is used to include country_id and state_id in address.
Fetch country and state and include the records in address. The object is included to
simplify the comparison of addresses.
:param dict address: An address with country and state defined in ISO 3166.
:return None:
"""
country = request.env["res.country"].search([
('code', '=', address.pop('country')),
], limit=1)
state_id = False
if state_code := address.pop('state', False):
state_id = country.state_ids.filtered(lambda state: state.code == state_code).id
address.update(country_id=country.id, state_id=state_id)
@route('/shop/update_address', type='jsonrpc', auth='public', website=True)
def shop_update_address(self, partner_id, address_type='billing', **kw):
partner_id = int(partner_id)
if not (order_sudo := request.cart):
return
ResPartner = request.env['res.partner'].sudo()
partner_sudo = ResPartner.browse(partner_id).exists()
children = ResPartner._search([
('id', 'child_of', order_sudo.partner_id.commercial_partner_id.id),
('type', 'in', ('invoice', 'delivery', 'other')),
])
if (
partner_sudo != order_sudo.partner_id
and partner_sudo != order_sudo.partner_id.commercial_partner_id
and partner_sudo.id not in children
):
raise Forbidden()
partner_fnames = set()
if (
address_type == 'billing'
and partner_sudo != order_sudo.partner_invoice_id
):
partner_fnames.add('partner_invoice_id')
elif (
address_type == 'delivery'
and partner_sudo != order_sudo.partner_shipping_id
):
partner_fnames.add('partner_shipping_id')
order_sudo._update_address(partner_id, partner_fnames)
# === CHECKOUT FLOW - EXTRA STEP METHODS === #
@route(['/shop/extra_info'], type='http', auth="public", website=True, sitemap=False, list_as_website_content=_lt("Shop Checkout - Extra Information"))
def extra_info(self, **post):
# Check that this option is activated
extra_step = request.website.viewref('website_sale.extra_info')
if not extra_step.active:
return request.redirect("/shop/payment")
# check that cart is valid
order_sudo = request.cart
redirection = self._check_cart(order_sudo)
open_editor = request.params.get('open_editor') == 'true'
# Do not redirect if it is to edit
# (the information is transmitted via the "open_editor" parameter in the url)
if not open_editor and redirection:
return redirection
values = {
'website_sale_order': order_sudo,
'post': post,
'escape': lambda x: x.replace("'", r"\'"),
'partner': order_sudo.partner_id.id,
'order': order_sudo,
}
values.update(request.website._get_checkout_step_values())
return request.render("website_sale.extra_info", values)
# === CHECKOUT FLOW - PAYMENT/CONFIRMATION METHODS === #
def _get_shop_payment_values(self, order, **kwargs):
checkout_page_values = {
'sale_order': order,
'website_sale_order': order,
'errors': self._get_shop_payment_errors(order),
'partner': order.partner_invoice_id,
'order': order,
'submit_button_label': _("Pay now"),
}
payment_form_values = {
**sale_portal.CustomerPortal._get_payment_values(
self, order, website_id=request.website.id
),
'display_submit_button': False, # The submit button is re-added outside the form.
'transaction_route': f'/shop/payment/transaction/{order.id}',
'landing_route': '/shop/payment/validate',
'sale_order_id': order.id, # Allow Stripe to check if tokenization is required.
}
return checkout_page_values | payment_form_values
@route(
_express_checkout_delivery_route + '/compute_taxes', type='jsonrpc', auth='public',
website=True, sitemap=False,
)
def express_checkout_shipping_address_compute_taxes(self):
order_sudo = request.cart
order_sudo._recompute_taxes()
amount_without_delivery = order_sudo._compute_amount_total_without_delivery()
return payment_utils.to_minor_currency_units(
amount_without_delivery, order_sudo.currency_id
)
def _get_shop_payment_errors(self, order):
""" Check that there is no error that should block the payment.
:param sale.order order: The sales order to pay
:return: A list of errors (error_title, error_message)
:rtype: list[tuple]
"""
errors = []
if order._has_deliverable_products() and not order._get_delivery_methods():
errors.append((
_("Sorry, we are unable to ship your order."),
_("No shipping method is available for your current order and shipping address."
" Please contact us for more information."),
))
return errors
@route('/shop/payment', type='http', auth='public', website=True, sitemap=False, list_as_website_content=_lt("Shop Payment"))
def shop_payment(self, **post):
""" Payment step. This page proposes several payment means based on available
payment.provider. State at this point :
- a draft sales order with lines; otherwise, clean context / session and
back to the shop
- no transaction in context / session, or only a draft one, if the customer
did go to a payment.provider website but closed the tab without
paying / canceling
"""
order_sudo = request.cart
if redirection := self._check_cart_and_addresses(order_sudo):
return redirection
order_sudo._recompute_cart()
render_values = self._get_shop_payment_values(order_sudo, **post)
render_values['only_services'] = order_sudo and order_sudo.only_services
if render_values['errors']:
render_values.pop('payment_methods_sudo', '')
render_values.pop('tokens_sudo', '')
render_values.update(request.website._get_checkout_step_values())
return request.render("website_sale.payment", render_values)
@route('/shop/payment/validate', type='http', auth="public", website=True, sitemap=False)
def shop_payment_validate(self, sale_order_id=None, **post):
""" Method that should be called by the server when receiving an update
for a transaction. State at this point :
- UDPATE ME
"""
if sale_order_id is None:
order_sudo = request.cart
if not order_sudo and 'sale_last_order_id' in request.session:
# Retrieve the last known order from the session if the session key `sale_order_id`
# was prematurely cleared. This is done to prevent the user from updating their cart
# after payment in case they don't return from payment through this route.
last_order_id = request.session['sale_last_order_id']
order_sudo = request.env['sale.order'].sudo().browse(last_order_id).exists()
else:
order_sudo = request.env['sale.order'].sudo().browse(sale_order_id)
assert order_sudo.id == request.session.get('sale_last_order_id')
if not order_sudo:
return request.redirect(self._get_shop_path())
errors = self._get_shop_payment_errors(order_sudo) if order_sudo.state != 'sale' else []
if errors:
first_error = errors[0] # only display first error
error_msg = f"{first_error[0]}\n{first_error[1]}"
raise ValidationError(error_msg)
tx_sudo = order_sudo.get_portal_last_transaction()
if order_sudo.amount_total and not tx_sudo:
return request.redirect(self._get_shop_path())
if not order_sudo.amount_total and not tx_sudo and order_sudo.state != 'sale':
order_sudo._check_cart_is_ready_to_be_paid()
# Only confirm the order if it wasn't already confirmed.
order_sudo._validate_order()
# clean context and session, then redirect to the confirmation page
request.website.sale_reset()
if tx_sudo and tx_sudo.state == 'draft':
return request.redirect(self._get_shop_path())
return request.redirect('/shop/confirmation')
@route(['/shop/confirmation'], type='http', auth="public", website=True, sitemap=False, list_as_website_content=_lt("Shop Confirmation"))
def shop_payment_confirmation(self, **post):
""" End of checkout process controller. Confirmation is basically seing
the status of a sale.order. State at this point :
- should not have any context / session info: clean them
- take a sale.order id, because we request a sale.order and are not
session dependant anymore
"""
sale_order_id = request.session.get('sale_last_order_id')
if sale_order_id:
order = request.env['sale.order'].sudo().browse(sale_order_id)
values = self._prepare_shop_payment_confirmation_values(order)
return request.render("website_sale.confirmation", values)
return request.redirect(self._get_shop_path())
def _prepare_shop_payment_confirmation_values(self, order):
"""
This method is called in the payment process route in order to prepare the dict
containing the values to be rendered by the confirmation template.
"""
return {
'order': order,
'website_sale_order': order,
'order_tracking_info': self.order_2_return_dict(order),
}
@route(['/shop/print'], type='http', auth="public", website=True, sitemap=False)
def print_saleorder(self, **kwargs):
sale_order_id = request.session.get('sale_last_order_id')
if sale_order_id:
pdf, _ = request.env['ir.actions.report'].sudo()._render_qweb_pdf('sale.action_report_saleorder', [sale_order_id])
pdfhttpheaders = [('Content-Type', 'application/pdf'), ('Content-Length', '%s' % len(pdf))]
return request.make_response(pdf, headers=pdfhttpheaders)
return request.redirect(self._get_shop_path())
# === CHECK METHODS === #
def _check_cart_and_addresses(self, order_sudo):
""" Check whether the cart and its addresses are valid, and redirect to the appropriate page
if not.
:param sale.order order_sudo: The cart to check.
:return: None if both the cart and its addresses are valid; otherwise, a redirection to the
appropriate page.
"""
if redirection := self._check_cart(order_sudo):
return redirection
if redirection := self._check_addresses(order_sudo):
return redirection
def _check_cart(self, order_sudo):
""" Check whether the cart is a valid, and redirect to the appropriate page if not.
The cart is only valid if:
- it exists and is in the draft state;
- it contains products (i.e., order lines);
- either the user is logged in, or public orders are allowed.
:param sale.order order_sudo: The cart to check.
:return: None if the cart is valid; otherwise, a redirection to the appropriate page.
"""
# Check that the cart exists and is in the draft state.
if not order_sudo or order_sudo.state != 'draft':
request.session['sale_order_id'] = None
request.session['sale_transaction_id'] = None
return request.redirect(self._get_shop_path())
# Check that the cart is not empty.
if not order_sudo.order_line:
return request.redirect('/shop/cart')
# Check that public orders are allowed.
if request.env.user._is_public() and request.website.account_on_checkout == 'mandatory':
return request.redirect('/web/login?redirect=/shop/checkout')
def _check_addresses(self, order_sudo):
""" Check whether the cart's addresses are complete and valid.
The addresses are complete and valid if:
- at least one address has been added;
- the delivery address is complete;
- the billing address is complete.
:param sale.order order_sudo: The cart whose addresses to check.
None if the cart is valid; otherwise, a redirection to the appropriate page.
:return: None if the cart's addresses are complete and valid; otherwise, a redirection to
the appropriate page.
"""
# Check that an address has been added.
if order_sudo._is_anonymous_cart():
return request.redirect('/shop/address')
# Check that the delivery address is complete.
delivery_partner_sudo = order_sudo.partner_shipping_id
if (
not order_sudo.only_services
and not self._check_delivery_address(delivery_partner_sudo)
and delivery_partner_sudo._can_be_edited_by_current_customer(order_sudo=order_sudo)
):
return request.redirect(
f'/shop/address?partner_id={delivery_partner_sudo.id}&address_type=delivery'
)
# Check that the billing address is complete.
invoice_partner_sudo = order_sudo.partner_invoice_id
if (
not self._check_billing_address(invoice_partner_sudo)
and invoice_partner_sudo._can_be_edited_by_current_customer(order_sudo=order_sudo)
):
return request.redirect(
f'/shop/address?partner_id={invoice_partner_sudo.id}&address_type=billing'
)
# ------------------------------------------------------
# Edit
# ------------------------------------------------------
@route(['/shop/config/product'], type='jsonrpc', auth='user')
def change_product_config(self, product_id, **options):
if not request.env.user.has_group('website.group_website_restricted_editor'):
raise NotFound()
product = request.env['product.template'].browse(product_id)
if "sequence" in options:
sequence = options["sequence"]
if sequence == "top":
product.set_sequence_top()
elif sequence == "bottom":
product.set_sequence_bottom()
elif sequence == "up":
product.set_sequence_up()
elif sequence == "down":
product.set_sequence_down()
if {"x", "y"} <= set(options):
product.write({'website_size_x': options["x"], 'website_size_y': options["y"]})
@route(['/shop/config/attribute'], type='jsonrpc', auth='user')
def change_attribute_config(self, attribute_id, **options):
if not request.env.user.has_group('website.group_website_restricted_editor'):
raise NotFound()
attribute = request.env['product.attribute'].browse(attribute_id)
if 'display_type' in options:
attribute.write({'display_type': options['display_type']})
request.env.registry.clear_cache('templates')
@route(['/shop/config/website'], type='jsonrpc', auth='user')
def _change_website_config(self, **options):
if not request.env.user.has_group('website.group_website_restricted_editor'):
raise NotFound()
current_website = request.env['website'].get_current_website()
# Restrict options we can write to.
writable_fields = {
'shop_page_container', 'shop_ppg', 'shop_ppr', 'shop_default_sort', 'shop_gap',
'shop_opt_products_design_classes', 'product_page_container',
'product_page_image_layout', 'product_page_image_width', 'product_page_grid_columns',
'product_page_image_spacing', 'product_page_image_ratio',
'product_page_image_ratio_mobile', 'product_page_cols_order',
'product_page_image_roundness', 'product_page_cta_design'
}
# Default ppg to 1.
if 'ppg' in options and not options['ppg']:
options['ppg'] = 1
if 'product_page_grid_columns' in options:
options['product_page_grid_columns'] = int(options['product_page_grid_columns'])
# Checkout Extra Step
if 'extra_step' in options:
extra_step_view = current_website.viewref('website_sale.extra_info')
extra_step = current_website._get_checkout_step('/shop/extra_info')
extra_step_view.active = extra_step.is_published = options.get('extra_step') == 'true'
write_vals = {k: v for k, v in options.items() if k in writable_fields}
if write_vals:
current_website.write(write_vals)
@route(['/shop/config/category'], type='jsonrpc', auth='user')
def _change_category_config(self, category_id, **options):
category = request.env['product.public.category'].browse(int(category_id))
if not category.exists():
raise NotFound()
# Restrict options we can write to.
targeted_options = {'show_category_title', 'show_category_description', 'align_category_content'}
modified_options = {option: value for option, value in options.items() if option in targeted_options}
if modified_options:
category.write(modified_options)
def order_lines_2_google_api(self, order_lines):
""" Transforms a list of order lines into a dict for google analytics """
ret = []
for line in order_lines.filtered(lambda line: not line.is_delivery):
product = line.product_id
ret.append({
'item_id': product.barcode or product.id,
'item_name': product.name or '-',
'item_category': product.categ_id.name or '-',
'price': line.price_unit,
'quantity': line.product_uom_qty,
})
return ret
def order_2_return_dict(self, order):
""" Returns the tracking_cart dict of the order for Google analytics basically defined to be inherited """
tracking_cart_dict = {
'transaction_id': order.id,
'affiliation': order.company_id.name,
'value': order.amount_total,
'tax': order.amount_tax,
'currency': order.currency_id.name,
'items': self.order_lines_2_google_api(order.order_line),
}
delivery_line = order.order_line.filtered('is_delivery')
if delivery_line:
tracking_cart_dict['shipping'] = delivery_line.price_unit
return tracking_cart_dict
# --------------------------------------------------------------------------
# Products Recently Viewed
# --------------------------------------------------------------------------
@route('/shop/products/recently_viewed_update', type='jsonrpc', auth='public', website=True)
def products_recently_viewed_update(self, product_id, **kwargs):
res = {}
visitor_sudo = request.env['website.visitor']._get_visitor_from_request(force_create=True)
visitor_sudo._add_viewed_product(product_id)
return res
@route('/shop/products/recently_viewed_delete', type='jsonrpc', auth='public', website=True)
def products_recently_viewed_delete(self, product_id=None, product_template_id=None, **kwargs):
if not (product_id or product_template_id):
return
visitor_sudo = request.env['website.visitor']._get_visitor_from_request()
if visitor_sudo:
domain = [('visitor_id', '=', visitor_sudo.id)]
if product_id:
domain += [('product_id', '=', int(product_id))]
else:
domain += [('product_id.product_tmpl_id', '=', int(product_template_id))]
request.env['website.track'].sudo().search(domain).unlink()
return {}
@route('/snippets/category/set_image', type='jsonrpc', auth='user')
def set_category_image(self, category_id, attachment_id):
"""
Set the cover image on the category.
:param int category_id: ID of the category to set the cover image.
:param int attachment_id: ID of the attachment containing the image data.
:raise Forbidden: If the user does not have website editing access
"""
if not request.env.user.has_group('website.group_website_restricted_editor'):
raise Forbidden()
category = request.env['product.public.category'].browse(category_id).exists()
if category:
image_data = request.env['ir.attachment'].browse(attachment_id).datas
category.cover_image = image_data
@staticmethod
def _populate_currency_and_pricelist(kwargs):
website = request.website
kwargs.update({
'currency_id': website.currency_id.id,
'pricelist_id': request.pricelist.id,
})
@staticmethod
def _validate_and_get_category(category):
""" Validate and return the `product.public.category` record corresponding to the provided
category, which can be a record, a record id, or a slug.
- If no category is provided, return an empty recordset.
- If a category is provided, but it doesn't exist or can't be accessed, raise a 404.
- If a valid category is provided, return the corresponding record.
:param str|product.public.category category: The category to validate and return.
:return: The validated category.
:rtype: product.public.category
"""
ProductCategory = request.env['product.public.category']
if not isinstance(category, ProductCategory.__class__) and category and not str(category).isdigit():
raise ValidationError(_("Invalid category."))
if (
(category := ProductCategory.browse(category and int(category)).exists())
and category.can_access_from_current_website()
):
return category
else:
return ProductCategory
@staticmethod
def _get_shop_path(category=None, page=0):
path = SHOP_PATH
if category:
slug = request.env['ir.http']._slug
path += f'/category/{slug(category)}'
if page:
path += f'/page/{page}'
return path
@staticmethod
def _get_filtered_query_string(query_string, keys_to_remove):
""" Return a filtered copy of the provided query string, where all keys in `keys_to_remove`
are removed.
Note: the query string shouldn't include the leading '?'.
:param str query_string: The query string to filter.
:param list(str) keys_to_remove: The keys to remove from the query string.
:return: The filtered query string.
:rtype: str
"""
query = urls.url_parse(f'?{query_string}').decode_query()
for key in keys_to_remove:
query.pop(key, False)
return urls.url_encode(query)
@staticmethod
def _get_attribute_value_dict(attribute_values):
""" Parses a list of attribute value query params, and returns a dict grouping attribute
value ids by attribute id.
:param list(str) attribute_values: The list of attribute value query parameters to parse.
:return: A dict grouping attribute value ids by attribute id.
:rtype: dict(int, list(int))
"""
attribute_value_pairs = [value.split('-') for value in attribute_values if value]
return {
int(pair[0]): [int(value_id) for value_id in pair[1].split(',')]
for pair in attribute_value_pairs
}