Files
odoo_source/addons/website/tools.py
T
Guillaume-gdi 88b016fdc4 [FIX] website, test_website: fix cache for navbar active element
Since [this other commit], the style indicating the active nav item
stopped working if the user added record pages to the navbar. This was
due to the cache system not being invalidated when switching from one
record page to another.

This commit fixes the issue by disabling the cache for the navbar if
there is a record page in the menu.

Other potential solutions were considered but ultimately rejected:
1. Using the record as a t-cache key. However, this would mean that if
you have 60,000 visible forum posts, you would end up with 60,000
different caches.
2. Activate the correct element using JavaScript. This would lead to a
duplicate logic in the JavaScript and the Python code, and it would
introduces a slight lag to add the active class on the correct nav item
(due to the time it takes to load and execute the JavaScript).

The chosen solution is the best compromise, as it maintains the cache
for most cases (website menu without records page links in it), nothing
change with this commit. For problematic cases (record pages in the
website menu), this commit disables the cache, which is a reasonable
trade-off.

Steps to reproduce the bug fixed by this commit:
- Edit a website's menu
- Add a link to a product page (e.g., customizable-desk)
- Add a link to another product (e.g., chair-floor-protection)
- Save the menu
- Click on the menu link to go to customizable-desk
=> At this point, the active menu element is correct
- Click on the menu link to go to chair-floor-protection
=> The active menu element does not update

This commit fixes the issue (a update of the website module is needed)
and adds a test to prevent regressions.

Notes:
- To see the issue locally, remove the --dev xml or --dev all arguments.
- The same issue was occurring with other record pages (blog posts, ..).
- We will introduce back the groups on menu and benefit from this new
  method to also disable the menu cache if one of the menu is linked to
  a group. See task-3800830

[this other commit]: https://github.com/odoo/odoo/commit/b0a2a41d78292cb8b9e53788d40c6dc5915a466d

opw-3694651
opw-3750925
opw-3781668

closes odoo/odoo#159006

X-original-commit: 43576cd424b6d0fc7da01142b5e6550e371ad1ff
Related: odoo/enterprise#59321
Signed-off-by: Romain Derie (rde) <rde@odoo.com>
Signed-off-by: Guillaume Dieleman (gdi) <gdi@odoo.com>
2024-03-25 10:00:51 +00:00

227 lines
7.8 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import contextlib
import re
import werkzeug.urls
from lxml import etree
from unittest.mock import Mock, MagicMock, patch
from werkzeug.exceptions import NotFound
from werkzeug.test import EnvironBuilder
import odoo
from odoo.tests.common import HttpCase, HOST
from odoo.tools.misc import hmac, DotDict, frozendict
@contextlib.contextmanager
def MockRequest(
env, *, path='/mockrequest', routing=True, multilang=True,
context=frozendict(), cookies=frozendict(), country_code=None,
website=None, remote_addr=HOST, environ_base=None, url_root=None,
# website_sale
sale_order_id=None, website_sale_current_pl=None,
):
lang_code = context.get('lang', env.context.get('lang', 'en_US'))
env = env(context=dict(context, lang=lang_code))
request = Mock(
# request
httprequest=Mock(
host='localhost',
path=path,
app=odoo.http.root,
environ=dict(
EnvironBuilder(
path=path,
base_url=HttpCase.base_url(),
environ_base=environ_base,
).get_environ(),
REMOTE_ADDR=remote_addr,
),
cookies=cookies,
referrer='',
remote_addr=remote_addr,
url_root=url_root,
args=[],
),
type='http',
future_response=odoo.http.FutureResponse(),
params={},
redirect=env['ir.http']._redirect,
session=DotDict(
odoo.http.get_default_session(),
geoip={'country_code': country_code},
sale_order_id=sale_order_id,
website_sale_current_pl=website_sale_current_pl,
context={'lang': ''},
),
geoip=odoo.http.GeoIP('127.0.0.1'),
db=env.registry.db_name,
env=env,
registry=env.registry,
cr=env.cr,
uid=env.uid,
context=env.context,
lang=env['res.lang']._lang_get(lang_code),
website=website,
render=lambda *a, **kw: '<MockResponse>',
)
if url_root is not None:
request.httprequest.url = werkzeug.urls.url_join(url_root, path)
if website:
request.website_routing = website.id
# The following code mocks match() to return a fake rule with a fake
# 'routing' attribute (routing=True) or to raise a NotFound
# exception (routing=False).
#
# router = odoo.http.root.get_db_router()
# rule, args = router.bind(...).match(path)
# # arg routing is True => rule.endpoint.routing == {...}
# # arg routing is False => NotFound exception
router = MagicMock()
match = router.return_value.bind.return_value.match
if routing:
match.return_value[0].routing = {
'type': 'http',
'website': True,
'multilang': multilang
}
else:
match.side_effect = NotFound
def update_context(**overrides):
request.context = dict(request.context, **overrides)
request.update_context = update_context
with contextlib.ExitStack() as s:
odoo.http._request_stack.push(request)
s.callback(odoo.http._request_stack.pop)
s.enter_context(patch('odoo.http.root.get_db_router', router))
yield request
# Fuzzy matching tools
def distance(s1="", s2="", limit=4):
"""
Limited Levenshtein-ish distance (inspired from Apache text common)
Note: this does not return quick results for simple cases (empty string, equal strings)
those checks should be done outside loops that use this function.
:param s1: first string
:param s2: second string
:param limit: maximum distance to take into account, return -1 if exceeded
:return: number of character changes needed to transform s1 into s2 or -1 if this exceeds the limit
"""
BIG = 100000 # never reached integer
if len(s1) > len(s2):
s1, s2 = s2, s1
l1 = len(s1)
l2 = len(s2)
if l2 - l1 > limit:
return -1
boundary = min(l1, limit) + 1
p = [i if i < boundary else BIG for i in range(0, l1 + 1)]
d = [BIG for _ in range(0, l1 + 1)]
for j in range(1, l2 + 1):
j2 = s2[j - 1]
d[0] = j
range_min = max(1, j - limit)
range_max = min(l1, j + limit)
if range_min > 1:
d[range_min - 1] = BIG
for i in range(range_min, range_max + 1):
if s1[i - 1] == j2:
d[i] = p[i - 1]
else:
d[i] = 1 + min(d[i - 1], p[i], p[i - 1])
p, d = d, p
return p[l1] if p[l1] <= limit else -1
def similarity_score(s1, s2):
"""
Computes a score that describes how much two strings are matching.
:param s1: first string
:param s2: second string
:return: float score, the higher the more similar
pairs returning non-positive scores should be considered non similar
"""
dist = distance(s1, s2)
if dist == -1:
return -1
set1 = set(s1)
score = len(set1.intersection(s2)) / len(set1)
score -= dist / len(s1)
score -= len(set1.symmetric_difference(s2)) / (len(s1) + len(s2))
return score
def text_from_html(html_fragment, collapse_whitespace=False):
"""
Returns the plain non-tag text from an html
:param html_fragment: document from which text must be extracted
:return: text extracted from the html
"""
# lxml requires one single root element
tree = etree.fromstring('<p>%s</p>' % html_fragment, etree.XMLParser(recover=True))
content = ' '.join(tree.itertext())
if collapse_whitespace:
content = re.sub('\\s+', ' ', content).strip()
return content
def get_base_domain(url, strip_www=False):
"""
Returns the domain of a given url without the scheme and the www. and the
final '/' if any.
:param url: url from which the domain must be extracted
:param strip_www: if True, strip the www. from the domain
:return: domain of the url
"""
if not url:
return ''
url = werkzeug.urls.url_parse(url).netloc
if strip_www and url.startswith('www.'):
url = url[4:]
return url
def add_form_signature(html_fragment, env_sudo):
for form in html_fragment.iter('form'):
if '/website/form/' not in form.attrib.get('action', ''):
continue
existing_hash_node = form.find('.//input[@type="hidden"][@name="website_form_signature"]')
if existing_hash_node is not None:
existing_hash_node.getparent().remove(existing_hash_node)
input_nodes = form.xpath('.//input[contains(@name, "email_")]')
form_values = {input_node.attrib['name']: input_node for input_node in input_nodes}
# if this form does not send an email, ignore. But at this stage,
# the value of email_to can still be None in case of default value
if 'email_to' not in form_values:
continue
email_to_value = form_values['email_to'].attrib.get('value')
if (not email_to_value
or (email_to_value == 'info@yourcompany.example.com'
and html_fragment.xpath('//span[@data-for="contactus_form"]'))):
# This means that the mail will be sent to the value of the dataFor
# which is the company email.
email_to_value = env_sudo.company.email or ''
has_cc = {'email_cc', 'email_bcc'} & form_values.keys()
value = email_to_value + (':email_cc' if has_cc else '')
hash_value = hmac(env_sudo, 'website_form_signature', value)
if has_cc:
hash_value += ':email_cc'
hash_node = etree.Element('input', attrib={'type': "hidden", 'value': hash_value, 'class': "form-control s_website_form_input s_website_form_custom", 'name': "website_form_signature"})
form_values['email_to'].addnext(hash_node)