summaryrefslogtreecommitdiff
path: root/addons/website/tests
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/website/tests
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website/tests')
-rw-r--r--addons/website/tests/__init__.py22
-rw-r--r--addons/website/tests/template_qweb_test.xml36
-rw-r--r--addons/website/tests/test_attachment.py51
-rw-r--r--addons/website/tests/test_base_url.py65
-rw-r--r--addons/website/tests/test_controllers.py46
-rw-r--r--addons/website/tests/test_converter.py81
-rw-r--r--addons/website/tests/test_crawl.py116
-rw-r--r--addons/website/tests/test_get_current_website.py123
-rw-r--r--addons/website/tests/test_lang_url.py62
-rw-r--r--addons/website/tests/test_menu.py171
-rw-r--r--addons/website/tests/test_page.py252
-rw-r--r--addons/website/tests/test_performance.py122
-rw-r--r--addons/website/tests/test_qweb.py161
-rw-r--r--addons/website/tests/test_res_users.py60
-rw-r--r--addons/website/tests/test_snippets.py12
-rw-r--r--addons/website/tests/test_theme.py14
-rw-r--r--addons/website/tests/test_ui.py194
-rw-r--r--addons/website/tests/test_views.py1432
-rw-r--r--addons/website/tests/test_views_inherit_module_update.py85
-rw-r--r--addons/website/tests/test_website_favicon.py36
-rw-r--r--addons/website/tests/test_website_reset_password.py77
-rw-r--r--addons/website/tests/test_website_visitor.py326
22 files changed, 3544 insertions, 0 deletions
diff --git a/addons/website/tests/__init__.py b/addons/website/tests/__init__.py
new file mode 100644
index 00000000..efde81df
--- /dev/null
+++ b/addons/website/tests/__init__.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import test_attachment
+from . import test_base_url
+from . import test_converter
+from . import test_crawl
+from . import test_get_current_website
+from . import test_lang_url
+from . import test_menu
+from . import test_page
+from . import test_performance
+from . import test_qweb
+from . import test_res_users
+from . import test_theme
+from . import test_ui
+from . import test_views
+from . import test_views_inherit_module_update
+from . import test_website_favicon
+from . import test_website_reset_password
+from . import test_website_visitor
+from . import test_controllers
+from . import test_snippets
diff --git a/addons/website/tests/template_qweb_test.xml b/addons/website/tests/template_qweb_test.xml
new file mode 100644
index 00000000..ac7dd4e3
--- /dev/null
+++ b/addons/website/tests/template_qweb_test.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <template id="website.test_bundle">
+ <script type="text/javascript" src="/web/static/lib/qweb/qweb2.js"></script>
+ <script type="text/javascript" src="http://test.external.link/javascript1.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/web/static/lib/jquery.ui/jquery-ui.css"/>
+ <link rel="stylesheet" href="http://test.external.link/style1.css"/>
+
+ <script type="text/javascript" src="/web/static/src/js/boot.js"></script>
+ <script type="text/javascript" src="http://test.external.link/javascript2.js"></script>
+
+ <link rel="stylesheet" href="http://test.external.link/style2.css"/>
+ </template>
+
+ <template id="website.test_template" name="test template 2">
+&lt;!DOCTYPE html&gt;
+<html>
+ <head>
+ <t t-call-assets="website.test_bundle" t-js="False"/>
+ <meta/>
+ <t t-call-assets="website.test_bundle" t-css="False"/>
+ </head>
+ <body>
+ <img src="http://test.external.link/img.png"/>
+ <img src="/website/static/img.png"/>
+ <a href="http://test.external.link/link">x</a>
+ <a href="/web/content/local_link">x</a>
+ <span t-attf-style="background-image: url('/web/image/2')" t-att-empty="False">xxx</span>
+ <div widget="html" t-field="user.signature"/>
+ <div widget="image" t-field="user.image_1920" t-options="{'widget': 'image'}"/>
+ </body>
+</html>
+ </template>
+
+</odoo> \ No newline at end of file
diff --git a/addons/website/tests/test_attachment.py b/addons/website/tests/test_attachment.py
new file mode 100644
index 00000000..17359eb7
--- /dev/null
+++ b/addons/website/tests/test_attachment.py
@@ -0,0 +1,51 @@
+import odoo.tests
+from odoo.tests.common import HOST
+from odoo.tools import config
+
+
+@odoo.tests.common.tagged('post_install', '-at_install')
+class TestWebsiteAttachment(odoo.tests.HttpCase):
+
+ def test_01_type_url_301_image(self):
+ IMD = self.env['ir.model.data']
+ IrAttachment = self.env['ir.attachment']
+
+ img1 = IrAttachment.create({
+ 'public': True,
+ 'name': 's_banner_default_image.jpg',
+ 'type': 'url',
+ 'url': '/website/static/src/img/snippets_demo/s_banner.jpg'
+ })
+
+ img2 = IrAttachment.create({
+ 'public': True,
+ 'name': 's_banner_default_image.jpg',
+ 'type': 'url',
+ 'url': '/web/image/test.an_image_url'
+ })
+
+ IMD.create({
+ 'name': 'an_image_url',
+ 'module': 'test',
+ 'model': img1._name,
+ 'res_id': img1.id,
+ })
+
+ IMD.create({
+ 'name': 'an_image_redirect_301',
+ 'module': 'test',
+ 'model': img2._name,
+ 'res_id': img2.id,
+ })
+
+ req = self.url_open('/web/image/test.an_image_url')
+ self.assertEqual(req.status_code, 200)
+
+ base = "http://%s:%s" % (HOST, config['http_port'])
+
+ req = self.opener.get(base + '/web/image/test.an_image_redirect_301', allow_redirects=False)
+ self.assertEqual(req.status_code, 301)
+ self.assertEqual(req.headers['Location'], base + '/web/image/test.an_image_url')
+
+ req = self.opener.get(base + '/web/image/test.an_image_redirect_301', allow_redirects=True)
+ self.assertEqual(req.status_code, 200)
diff --git a/addons/website/tests/test_base_url.py b/addons/website/tests/test_base_url.py
new file mode 100644
index 00000000..c14a6bc3
--- /dev/null
+++ b/addons/website/tests/test_base_url.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from lxml.html import document_fromstring
+
+import odoo.tests
+
+
+class TestUrlCommon(odoo.tests.HttpCase):
+ def setUp(self):
+ super(TestUrlCommon, self).setUp()
+ self.domain = 'http://' + odoo.tests.HOST
+ self.website = self.env['website'].create({
+ 'name': 'test base url',
+ 'domain': self.domain,
+ })
+
+ lang_fr = self.env['res.lang']._activate_lang('fr_FR')
+ self.website.language_ids = self.env.ref('base.lang_en') + lang_fr
+ self.website.default_lang_id = self.env.ref('base.lang_en')
+
+ def _assertCanonical(self, url, canonical_url):
+ res = self.url_open(url)
+ canonical_link = document_fromstring(res.content).xpath("/html/head/link[@rel='canonical']")
+ self.assertEqual(len(canonical_link), 1)
+ self.assertEqual(canonical_link[0].attrib["href"], canonical_url)
+
+
+@odoo.tests.tagged('-at_install', 'post_install')
+class TestBaseUrl(TestUrlCommon):
+ def test_01_base_url(self):
+ ICP = self.env['ir.config_parameter']
+ icp_base_url = ICP.sudo().get_param('web.base.url')
+
+ # Test URL is correct for the website itself when the domain is set
+ self.assertEqual(self.website.get_base_url(), self.domain)
+
+ # Test URL is correct for a model without website_id
+ without_website_id = self.env['ir.attachment'].create({'name': 'test base url'})
+ self.assertEqual(without_website_id.get_base_url(), icp_base_url)
+
+ # Test URL is correct for a model with website_id...
+ with_website_id = self.env['res.partner'].create({'name': 'test base url'})
+
+ # ...when no website is set on the model
+ with_website_id.website_id = False
+ self.assertEqual(with_website_id.get_base_url(), icp_base_url)
+
+ # ...when the website is correctly set
+ with_website_id.website_id = self.website
+ self.assertEqual(with_website_id.get_base_url(), self.domain)
+
+ # ...when the set website doesn't have a domain
+ self.website.domain = False
+ self.assertEqual(with_website_id.get_base_url(), icp_base_url)
+
+ # Test URL is correct for the website itself when no domain is set
+ self.assertEqual(self.website.get_base_url(), icp_base_url)
+
+ def test_02_canonical_url(self):
+ self._assertCanonical('/', self.domain + '/')
+ self._assertCanonical('/?debug=1', self.domain + '/')
+ self._assertCanonical('/a-page', self.domain + '/a-page')
+ self._assertCanonical('/en_US', self.domain + '/')
+ self._assertCanonical('/fr_FR', self.domain + '/fr/')
diff --git a/addons/website/tests/test_controllers.py b/addons/website/tests/test_controllers.py
new file mode 100644
index 00000000..9bb167fe
--- /dev/null
+++ b/addons/website/tests/test_controllers.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+
+from odoo import tests
+from odoo.tools import mute_logger
+
+
+@tests.tagged('post_install', '-at_install')
+class TestControllers(tests.HttpCase):
+
+ @mute_logger('odoo.addons.http_routing.models.ir_http', 'odoo.http')
+ def test_last_created_pages_autocompletion(self):
+ self.authenticate("admin", "admin")
+ Page = self.env['website.page']
+ last_5_url_edited = []
+ base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+ suggested_links_url = base_url + '/website/get_suggested_links'
+
+ for i in range(0, 10):
+ new_page = Page.create({
+ 'name': 'Generic',
+ 'type': 'qweb',
+ 'arch': '''
+ <div>content</div>
+ ''',
+ 'key': "test.generic_view-%d" % i,
+ 'url': "/generic-%d" % i,
+ 'is_published': True,
+ })
+ if i % 2 == 0:
+ # mark as old
+ new_page._write({'write_date': '2020-01-01'})
+ else:
+ last_5_url_edited.append(new_page.url)
+
+ res = self.opener.post(url=suggested_links_url, json={'params': {'needle': '/'}})
+ resp = json.loads(res.content)
+ assert 'result' in resp
+ suggested_links = resp['result']
+ last_modified_history = next(o for o in suggested_links['others'] if o["title"] == "Last modified pages")
+ last_modified_values = map(lambda o: o['value'], last_modified_history['values'])
+
+ matching_pages = set(map(lambda o: o['value'], suggested_links['matching_pages']))
+ self.assertEqual(set(last_modified_values), set(last_5_url_edited) - matching_pages)
diff --git a/addons/website/tests/test_converter.py b/addons/website/tests/test_converter.py
new file mode 100644
index 00000000..9a688c74
--- /dev/null
+++ b/addons/website/tests/test_converter.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.http_routing.models.ir_http import slugify, unslug
+from odoo.tests.common import BaseCase
+
+
+class TestUnslug(BaseCase):
+
+ def test_unslug(self):
+ tests = {
+ '': (None, None),
+ 'foo': (None, None),
+ 'foo-': (None, None),
+ '-': (None, None),
+ 'foo-1': ('foo', 1),
+ 'foo-bar-1': ('foo-bar', 1),
+ 'foo--1': ('foo', -1),
+ '1': (None, 1),
+ '1-1': ('1', 1),
+ '--1': (None, None),
+ 'foo---1': (None, None),
+ 'foo1': (None, None),
+ }
+
+ for slug, expected in tests.items():
+ self.assertEqual(unslug(slug), expected)
+
+class TestTitleToSlug(BaseCase):
+ """
+ Those tests should pass with or without python-slugify
+ See website/models/website.py slugify method
+ """
+
+ def test_spaces(self):
+ self.assertEqual(
+ "spaces",
+ slugify(u" spaces ")
+ )
+
+ def test_unicode(self):
+ self.assertEqual(
+ "heterogeneite",
+ slugify(u"hétérogénéité")
+ )
+
+ def test_underscore(self):
+ self.assertEqual(
+ "one-two",
+ slugify(u"one_two")
+ )
+
+ def test_caps(self):
+ self.assertEqual(
+ "camelcase",
+ slugify(u"CamelCase")
+ )
+
+ def test_special_chars(self):
+ self.assertEqual(
+ "o-d-o-o",
+ slugify(u"o!#d{|\o/@~o&%^?")
+ )
+
+ def test_str_to_unicode(self):
+ self.assertEqual(
+ "espana",
+ slugify("España")
+ )
+
+ def test_numbers(self):
+ self.assertEqual(
+ "article-1",
+ slugify(u"Article 1")
+ )
+
+ def test_all(self):
+ self.assertEqual(
+ "do-you-know-martine-a-la-plage",
+ slugify(u"Do YOU know 'Martine à la plage' ?")
+ )
diff --git a/addons/website/tests/test_crawl.py b/addons/website/tests/test_crawl.py
new file mode 100644
index 00000000..d210fd9b
--- /dev/null
+++ b/addons/website/tests/test_crawl.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+import time
+
+import lxml.html
+from werkzeug import urls
+
+import odoo
+import re
+
+from odoo.addons.base.tests.common import HttpCaseWithUserDemo
+
+_logger = logging.getLogger(__name__)
+
+
+@odoo.tests.common.tagged('post_install', '-at_install', 'crawl')
+class Crawler(HttpCaseWithUserDemo):
+ """ Test suite crawling an Odoo CMS instance and checking that all
+ internal links lead to a 200 response.
+
+ If a username and a password are provided, authenticates the user before
+ starting the crawl
+ """
+
+ def setUp(self):
+ super(Crawler, self).setUp()
+
+ if hasattr(self.env['res.partner'], 'grade_id'):
+ # Create at least one published parter, so that /partners doesn't
+ # return a 404
+ grade = self.env['res.partner.grade'].create({
+ 'name': 'A test grade',
+ 'website_published': True,
+ })
+ self.env['res.partner'].create({
+ 'name': 'A Company for /partners',
+ 'is_company': True,
+ 'grade_id': grade.id,
+ 'website_published': True,
+ })
+
+ def crawl(self, url, seen=None, msg=''):
+ if seen is None:
+ seen = set()
+
+ url_slug = re.sub(r"[/](([^/=?&]+-)?[0-9]+)([/]|$)", '/<slug>/', url)
+ url_slug = re.sub(r"([^/=?&]+)=[^/=?&]+", '\g<1>=param', url_slug)
+ if url_slug in seen:
+ return seen
+ else:
+ seen.add(url_slug)
+
+ _logger.info("%s %s", msg, url)
+ r = self.url_open(url, allow_redirects=False)
+ if r.status_code in (301, 302):
+ # check local redirect to avoid fetch externals pages
+ new_url = r.headers.get('Location')
+ current_url = r.url
+ if urls.url_parse(new_url).netloc != urls.url_parse(current_url).netloc:
+ return seen
+ r = self.url_open(new_url)
+
+ code = r.status_code
+ self.assertIn(code, range(200, 300), "%s Fetching %s returned error response (%d)" % (msg, url, code))
+
+ if r.headers['Content-Type'].startswith('text/html'):
+ doc = lxml.html.fromstring(r.content)
+ for link in doc.xpath('//a[@href]'):
+ href = link.get('href')
+
+ parts = urls.url_parse(href)
+ # href with any fragment removed
+ href = parts.replace(fragment='').to_url()
+
+ # FIXME: handle relative link (not parts.path.startswith /)
+ if parts.netloc or \
+ not parts.path.startswith('/') or \
+ parts.path == '/web' or\
+ parts.path.startswith('/web/') or \
+ parts.path.startswith('/en_US/') or \
+ (parts.scheme and parts.scheme not in ('http', 'https')):
+ continue
+
+ self.crawl(href, seen, msg)
+ return seen
+
+ def test_10_crawl_public(self):
+ t0 = time.time()
+ t0_sql = self.registry.test_cr.sql_log_count
+ seen = self.crawl('/', msg='Anonymous Coward')
+ count = len(seen)
+ duration = time.time() - t0
+ sql = self.registry.test_cr.sql_log_count - t0_sql
+ _logger.runbot("public crawled %s urls in %.2fs %s queries, %.3fs %.2fq per request, ", count, duration, sql, duration / count, float(sql) / count)
+
+ def test_20_crawl_demo(self):
+ t0 = time.time()
+ t0_sql = self.registry.test_cr.sql_log_count
+ self.authenticate('demo', 'demo')
+ seen = self.crawl('/', msg='demo')
+ count = len(seen)
+ duration = time.time() - t0
+ sql = self.registry.test_cr.sql_log_count - t0_sql
+ _logger.runbot("demo crawled %s urls in %.2fs %s queries, %.3fs %.2fq per request", count, duration, sql, duration / count, float(sql) / count)
+
+ def test_30_crawl_admin(self):
+ t0 = time.time()
+ t0_sql = self.registry.test_cr.sql_log_count
+ self.authenticate('admin', 'admin')
+ seen = self.crawl('/', msg='admin')
+ count = len(seen)
+ duration = time.time() - t0
+ sql = self.registry.test_cr.sql_log_count - t0_sql
+ _logger.runbot("admin crawled %s urls in %.2fs %s queries, %.3fs %.2fq per request", count, duration, sql, duration / count, float(sql) / count)
diff --git a/addons/website/tests/test_get_current_website.py b/addons/website/tests/test_get_current_website.py
new file mode 100644
index 00000000..da36762a
--- /dev/null
+++ b/addons/website/tests/test_get_current_website.py
@@ -0,0 +1,123 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import tagged
+from odoo.tests.common import TransactionCase
+
+
+@tagged('post_install', '-at_install')
+class TestGetCurrentWebsite(TransactionCase):
+
+ def test_01_get_current_website_id(self):
+ """Make sure `_get_current_website_id works`."""
+
+ Website = self.env['website']
+
+ # clean initial state
+ website1 = self.env.ref('website.default_website')
+ website1.domain = ''
+ website1.country_group_ids = False
+
+ website2 = Website.create({
+ 'name': 'My Website 2',
+ 'domain': '',
+ 'country_group_ids': False,
+ })
+
+ country1 = self.env['res.country'].create({'name': "My Country 1"})
+ country2 = self.env['res.country'].create({'name': "My Country 2"})
+ country3 = self.env['res.country'].create({'name': "My Country 3"})
+ country4 = self.env['res.country'].create({'name': "My Country 4"})
+ country5 = self.env['res.country'].create({'name': "My Country 5"})
+
+ country_group_1_2 = self.env['res.country.group'].create({
+ 'name': "My Country Group 1-2",
+ 'country_ids': [(6, 0, (country1 + country2 + country5).ids)],
+ })
+ country_group_3 = self.env['res.country.group'].create({
+ 'name': "My Country Group 3",
+ 'country_ids': [(6, 0, (country3 + country5).ids)],
+ })
+
+ # CASE: no domain, no country: get first
+ self.assertEqual(Website._get_current_website_id('', False), website1.id)
+ self.assertEqual(Website._get_current_website_id('', country3.id), website1.id)
+
+ # CASE: no domain, given country: get by country
+ website1.country_group_ids = country_group_1_2
+ website2.country_group_ids = country_group_3
+
+ self.assertEqual(Website._get_current_website_id('', country1.id), website1.id)
+ self.assertEqual(Website._get_current_website_id('', country2.id), website1.id)
+ self.assertEqual(Website._get_current_website_id('', country3.id), website2.id)
+
+ # CASE: no domain, wrong country: get first
+ self.assertEqual(Website._get_current_website_id('', country4.id), Website.search([]).sorted('country_group_ids')[0].id)
+
+ # CASE: no domain, multiple country: get first
+ self.assertEqual(Website._get_current_website_id('', country5.id), website1.id)
+
+ # setup domain
+ website1.domain = 'my-site-1.fr'
+ website2.domain = 'https://my2ndsite.com:80'
+
+ website1.country_group_ids = False
+ website2.country_group_ids = False
+
+ # CASE: domain set: get matching domain
+ self.assertEqual(Website._get_current_website_id('my-site-1.fr', False), website1.id)
+
+ # CASE: domain set: get matching domain (scheme and port supported)
+ self.assertEqual(Website._get_current_website_id('my-site-1.fr:8069', False), website1.id)
+
+ self.assertEqual(Website._get_current_website_id('my2ndsite.com:80', False), website2.id)
+ self.assertEqual(Website._get_current_website_id('my2ndsite.com:8069', False), website2.id)
+ self.assertEqual(Website._get_current_website_id('my2ndsite.com', False), website2.id)
+
+ # CASE: domain set, wrong domain: get first
+ self.assertEqual(Website._get_current_website_id('test.com', False), website1.id)
+
+ # CASE: subdomain: not supported
+ self.assertEqual(Website._get_current_website_id('www.my2ndsite.com', False), website1.id)
+
+ # CASE: domain set, given country: get by domain in priority
+ website1.country_group_ids = country_group_1_2
+ website2.country_group_ids = country_group_3
+
+ self.assertEqual(Website._get_current_website_id('my2ndsite.com', country1.id), website2.id)
+ self.assertEqual(Website._get_current_website_id('my2ndsite.com', country2.id), website2.id)
+ self.assertEqual(Website._get_current_website_id('my-site-1.fr', country3.id), website1.id)
+
+ # CASE: domain set, wrong country: get first for domain
+ self.assertEqual(Website._get_current_website_id('my2ndsite.com', country4.id), website2.id)
+
+ # CASE: domain set, multiple country: get first for domain
+ website1.domain = website2.domain
+ self.assertEqual(Website._get_current_website_id('my2ndsite.com', country5.id), website1.id)
+
+ # CASE: overlapping domain: get exact match
+ website1.domain = 'site-1.com'
+ website2.domain = 'even-better-site-1.com'
+ self.assertEqual(Website._get_current_website_id('site-1.com', False), website1.id)
+ self.assertEqual(Website._get_current_website_id('even-better-site-1.com', False), website2.id)
+
+ # CASE: case insensitive
+ website1.domain = 'Site-1.com'
+ website2.domain = 'Even-Better-site-1.com'
+ self.assertEqual(Website._get_current_website_id('sitE-1.com', False), website1.id)
+ self.assertEqual(Website._get_current_website_id('even-beTTer-site-1.com', False), website2.id)
+
+ # CASE: same domain, different port
+ website1.domain = 'site-1.com:80'
+ website2.domain = 'site-1.com:81'
+ self.assertEqual(Website._get_current_website_id('site-1.com:80', False), website1.id)
+ self.assertEqual(Website._get_current_website_id('site-1.com:81', False), website2.id)
+ self.assertEqual(Website._get_current_website_id('site-1.com:82', False), website1.id)
+ self.assertEqual(Website._get_current_website_id('site-1.com', False), website1.id)
+
+ def test_02_signup_user_website_id(self):
+ website = self.env.ref('website.default_website')
+ website.specific_user_account = True
+
+ user = self.env['res.users'].create({'website_id': website.id, 'login': 'sad@mail.com', 'name': 'Hope Fully'})
+ self.assertTrue(user.website_id == user.partner_id.website_id == website)
diff --git a/addons/website/tests/test_lang_url.py b/addons/website/tests/test_lang_url.py
new file mode 100644
index 00000000..51153ade
--- /dev/null
+++ b/addons/website/tests/test_lang_url.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.http_routing.models.ir_http import url_lang
+from odoo.addons.website.tools import MockRequest
+from odoo.tests import HttpCase, tagged
+
+
+@tagged('-at_install', 'post_install')
+class TestLangUrl(HttpCase):
+ def setUp(self):
+ super(TestLangUrl, self).setUp()
+
+ # Simulate multi lang without loading translations
+ self.website = self.env.ref('website.default_website')
+ self.lang_fr = self.env['res.lang']._activate_lang('fr_FR')
+ self.lang_fr.write({'url_code': 'fr'})
+ self.website.language_ids = self.env.ref('base.lang_en') + self.lang_fr
+ self.website.default_lang_id = self.env.ref('base.lang_en')
+
+ def test_01_url_lang(self):
+ with MockRequest(self.env, website=self.website):
+ self.assertEqual(url_lang('', '[lang]'), '/[lang]/hello/', "`[lang]` is used to be replaced in the url_return after installing a language, it should not be replaced or removed.")
+
+ def test_02_url_redirect(self):
+ url = '/fr_WHATEVER/contactus'
+ r = self.url_open(url)
+ self.assertEqual(r.status_code, 200)
+ self.assertTrue(r.url.endswith('/fr/contactus'), "fr_WHATEVER should be forwarded to 'fr_FR' lang as closest match")
+
+ url = '/fr_FR/contactus'
+ r = self.url_open(url)
+ self.assertEqual(r.status_code, 200)
+ self.assertTrue(r.url.endswith('/fr/contactus'), "lang in url should use url_code ('fr' in this case)")
+
+ def test_03_url_cook_lang_not_available(self):
+ """ An activated res.lang should not be displayed in the frontend if not a website lang. """
+ self.website.language_ids = self.env.ref('base.lang_en')
+ r = self.url_open('/fr/contactus')
+ self.assertTrue('lang="en-US"' in r.text, "french should not be displayed as not a frontend lang")
+
+ def test_04_url_cook_lang_not_available(self):
+ """ `nearest_lang` should filter out lang not available in frontend.
+ Eg: 1. go in backend in english -> request.context['lang'] = `en_US`
+ 2. go in frontend, the request.context['lang'] is passed through
+ `nearest_lang` which should not return english. More then a
+ misbehavior it will crash in website language selector template.
+ """
+ # 1. Load backend
+ self.authenticate('admin', 'admin')
+ r = self.url_open('/web')
+ self.assertTrue('"lang": "en_US"' in r.text, "ensure english was loaded")
+
+ # 2. Remove en_US from frontend
+ self.website.language_ids = self.lang_fr
+ self.website.default_lang_id = self.lang_fr
+
+ # 3. Ensure visiting /contactus do not crash
+ url = '/contactus'
+ r = self.url_open(url)
+ self.assertEqual(r.status_code, 200)
+ self.assertTrue('lang="fr-FR"' in r.text, "Ensure contactus did not soft crash + loaded in correct lang")
diff --git a/addons/website/tests/test_menu.py b/addons/website/tests/test_menu.py
new file mode 100644
index 00000000..5a0fe9c4
--- /dev/null
+++ b/addons/website/tests/test_menu.py
@@ -0,0 +1,171 @@
+# coding: utf-8
+
+import json
+
+from odoo.tests import common
+
+
+class TestMenu(common.TransactionCase):
+ def setUp(self):
+ super(TestMenu, self).setUp()
+ self.nb_website = self.env['website'].search_count([])
+
+ def test_01_menu_got_duplicated(self):
+ Menu = self.env['website.menu']
+ total_menu_items = Menu.search_count([])
+
+ self.menu_root = Menu.create({
+ 'name': 'Root',
+ })
+
+ self.menu_child = Menu.create({
+ 'name': 'Child',
+ 'parent_id': self.menu_root.id,
+ })
+
+ self.assertEqual(total_menu_items + self.nb_website * 2, Menu.search_count([]), "Creating a menu without a website_id should create this menu for every website_id")
+
+ def test_02_menu_count(self):
+ Menu = self.env['website.menu']
+ total_menu_items = Menu.search_count([])
+
+ top_menu = self.env['website'].get_current_website().menu_id
+ data = [
+ {
+ 'id': 'new-1',
+ 'parent_id': top_menu.id,
+ 'name': 'New Menu Specific 1',
+ 'url': '/new-specific-1',
+ 'is_mega_menu': False,
+ },
+ {
+ 'id': 'new-2',
+ 'parent_id': top_menu.id,
+ 'name': 'New Menu Specific 2',
+ 'url': '/new-specific-2',
+ 'is_mega_menu': False,
+ }
+ ]
+ Menu.save(1, {'data': data, 'to_delete': []})
+
+ self.assertEqual(total_menu_items + 2, Menu.search_count([]), "Creating 2 new menus should create only 2 menus records")
+
+ def test_03_default_menu_for_new_website(self):
+ Website = self.env['website']
+ Menu = self.env['website.menu']
+ total_menu_items = Menu.search_count([])
+
+ # Simulating website.menu created on module install (blog, shop, forum..) that will be created on default menu tree
+ default_menu = self.env.ref('website.main_menu')
+ Menu.create({
+ 'name': 'Sub Default Menu',
+ 'parent_id': default_menu.id,
+ })
+ self.assertEqual(total_menu_items + 1 + self.nb_website, Menu.search_count([]), "Creating a default child menu should create it as such and copy it on every website")
+
+ # Ensure new website got a top menu
+ total_menus = Menu.search_count([])
+ Website.create({'name': 'new website'})
+ self.assertEqual(total_menus + 4, Menu.search_count([]), "New website's bootstraping should have duplicate default menu tree (Top/Home/Contactus/Sub Default Menu)")
+
+ def test_04_specific_menu_translation(self):
+ Translation = self.env['ir.translation']
+ Menu = self.env['website.menu']
+ existing_menus = Menu.search([])
+
+ default_menu = self.env.ref('website.main_menu')
+ template_menu = Menu.create({
+ 'parent_id': default_menu.id,
+ 'name': 'Menu in english',
+ 'url': 'turlututu',
+ })
+ new_menus = Menu.search([]) - existing_menus
+ specific1, specific2 = new_menus.with_context(lang='fr_FR') - template_menu
+
+ # create fr_FR translation for template menu
+ self.env.ref('base.lang_fr').active = True
+ template_menu.with_context(lang='fr_FR').name = 'Menu en français'
+ Translation.search([
+ ('name', '=', 'website.menu,name'), ('res_id', '=', template_menu.id),
+ ]).module = 'website'
+ self.assertEqual(specific1.name, 'Menu in english',
+ 'Translating template menu does not translate specific menu')
+
+ # have different translation for specific website
+ specific1.name = 'Menu in french'
+
+ # loading translation add missing specific translation
+ Translation._load_module_terms(['website'], ['fr_FR'])
+ Menu.invalidate_cache(['name'])
+ self.assertEqual(specific1.name, 'Menu in french',
+ 'Load translation without overwriting keep existing translation')
+ self.assertEqual(specific2.name, 'Menu en français',
+ 'Load translation add missing translation from template menu')
+
+ # loading translation with overwrite sync all translations from menu template
+ Translation._load_module_terms(['website'], ['fr_FR'], overwrite=True)
+ Menu.invalidate_cache(['name'])
+ self.assertEqual(specific1.name, 'Menu en français',
+ 'Load translation with overwriting update existing menu from template')
+
+ def test_05_default_menu_unlink(self):
+ Menu = self.env['website.menu']
+ total_menu_items = Menu.search_count([])
+
+ default_menu = self.env.ref('website.main_menu')
+ default_menu.child_id[0].unlink()
+ self.assertEqual(total_menu_items - 1 - self.nb_website, Menu.search_count([]), "Deleting a default menu item should delete its 'copies' (same URL) from website's menu trees. In this case, the default child menu and its copies on website 1 and website 2")
+
+
+class TestMenuHttp(common.HttpCase):
+ def test_01_menu_page_m2o(self):
+ # 1. Create a page with a menu
+ Menu = self.env['website.menu']
+ Page = self.env['website.page']
+ page_url = '/page_specific'
+ page = Page.create({
+ 'url': page_url,
+ 'website_id': 1,
+ # ir.ui.view properties
+ 'name': 'Base',
+ 'type': 'qweb',
+ 'arch': '<div>Specific View</div>',
+ 'key': 'test.specific_view',
+ })
+ menu = Menu.create({
+ 'name': 'Page Specific menu',
+ 'page_id': page.id,
+ 'url': page_url,
+ 'website_id': 1,
+ })
+
+ # 2. Edit the menu URL to a 'reserved' URL
+ data = {
+ 'id': menu.id,
+ 'parent_id': menu.parent_id.id,
+ 'name': menu.name,
+ 'url': '/website/info',
+ }
+ self.authenticate("admin", "admin")
+ # `Menu.save(1, {'data': [data], 'to_delete': []})` would have been
+ # ideal but need a full frontend context to generate routing maps,
+ # router and registry, even MockRequest is not enough
+ self.url_open('/web/dataset/call_kw', data=json.dumps({
+ "params": {
+ 'model': 'website.menu',
+ 'method': 'save',
+ 'args': [1, {'data': [data], 'to_delete': []}],
+ 'kwargs': {},
+ },
+ }), headers={"Content-Type": "application/json"})
+
+ self.assertFalse(menu.page_id, "M2o should have been unset as this is a reserved URL.")
+ self.assertEqual(menu.url, '/website/info', "Menu URL should have changed.")
+ self.assertEqual(page.url, page_url, "Page's URL shouldn't have changed.")
+
+ # 3. Edit the menu URL back to the page URL
+ data['url'] = page_url
+ Menu.save(1, {'data': [data], 'to_delete': []})
+ self.assertEqual(menu.page_id, page,
+ "M2o should have been set back, as there was a page found with the new URL set on the menu.")
+ self.assertTrue(page.url == menu.url == page_url)
diff --git a/addons/website/tests/test_page.py b/addons/website/tests/test_page.py
new file mode 100644
index 00000000..7d7d0928
--- /dev/null
+++ b/addons/website/tests/test_page.py
@@ -0,0 +1,252 @@
+# coding: utf-8
+from odoo.tests import common, HttpCase, tagged
+
+
+@tagged('-at_install', 'post_install')
+class TestPage(common.TransactionCase):
+ def setUp(self):
+ super(TestPage, self).setUp()
+ View = self.env['ir.ui.view']
+ Page = self.env['website.page']
+ Menu = self.env['website.menu']
+
+ self.base_view = View.create({
+ 'name': 'Base',
+ 'type': 'qweb',
+ 'arch': '<div>content</div>',
+ 'key': 'test.base_view',
+ })
+
+ self.extension_view = View.create({
+ 'name': 'Extension',
+ 'mode': 'extension',
+ 'inherit_id': self.base_view.id,
+ 'arch': '<div position="inside">, extended content</div>',
+ 'key': 'test.extension_view',
+ })
+
+ self.page_1 = Page.create({
+ 'view_id': self.base_view.id,
+ 'url': '/page_1',
+ })
+
+ self.page_1_menu = Menu.create({
+ 'name': 'Page 1 menu',
+ 'page_id': self.page_1.id,
+ 'website_id': 1,
+ })
+
+ def test_copy_page(self):
+ View = self.env['ir.ui.view']
+ Page = self.env['website.page']
+ Menu = self.env['website.menu']
+ # Specific page
+ self.specific_view = View.create({
+ 'name': 'Base',
+ 'type': 'qweb',
+ 'arch': '<div>Specific View</div>',
+ 'key': 'test.specific_view',
+ })
+ self.page_specific = Page.create({
+ 'view_id': self.specific_view.id,
+ 'url': '/page_specific',
+ 'website_id': 1,
+ })
+ self.page_specific_menu = Menu.create({
+ 'name': 'Page Specific menu',
+ 'page_id': self.page_specific.id,
+ 'website_id': 1,
+ })
+ total_pages = Page.search_count([])
+ total_menus = Menu.search_count([])
+ # Copying a specific page should create a new page with an unique URL (suffixed by -X)
+ Page.clone_page(self.page_specific.id, clone_menu=True)
+ cloned_page = Page.search([('url', '=', '/page_specific-1')])
+ cloned_menu = Menu.search([('url', '=', '/page_specific-1')])
+ self.assertEqual(len(cloned_page), 1, "A page with an URL /page_specific-1 should've been created")
+ self.assertEqual(Page.search_count([]), total_pages + 1, "Should have cloned the page")
+ # It should also copy its menu with new url/name/page_id (if the page has a menu)
+ self.assertEqual(len(cloned_menu), 1, "A specific page (with a menu) being cloned should have it's menu also cloned")
+ self.assertEqual(cloned_menu.page_id, cloned_page, "The new cloned menu and the new cloned page should be linked (m2o)")
+ self.assertEqual(Menu.search_count([]), total_menus + 1, "Should have cloned the page menu")
+ Page.clone_page(self.page_specific.id, page_name="about-us", clone_menu=True)
+ cloned_page_about_us = Page.search([('url', '=', '/about-us')])
+ cloned_menu_about_us = Menu.search([('url', '=', '/about-us')])
+ self.assertEqual(len(cloned_page_about_us), 1, "A page with an URL /about-us should've been created")
+ self.assertEqual(len(cloned_menu_about_us), 1, "A specific page (with a menu) being cloned should have it's menu also cloned")
+ self.assertEqual(cloned_menu_about_us.page_id, cloned_page_about_us, "The new cloned menu and the new cloned page should be linked (m2o)")
+ # It should also copy its menu with new url/name/page_id (if the page has a menu)
+ self.assertEqual(Menu.search_count([]), total_menus + 2, "Should have cloned the page menu")
+
+ total_pages = Page.search_count([])
+ total_menus = Menu.search_count([])
+
+ # Copying a generic page should create a specific page with same URL
+ Page.clone_page(self.page_1.id, clone_menu=True)
+ cloned_generic_page = Page.search([('url', '=', '/page_1'), ('id', '!=', self.page_1.id), ('website_id', '!=', False)])
+ self.assertEqual(len(cloned_generic_page), 1, "A generic page being cloned should create a specific one for the current website")
+ self.assertEqual(cloned_generic_page.url, self.page_1.url, "The URL of the cloned specific page should be the same as the generic page it has been cloned from")
+ self.assertEqual(Page.search_count([]), total_pages + 1, "Should have cloned the generic page as a specific page for this website")
+ self.assertEqual(Menu.search_count([]), total_menus, "It should not create a new menu as the generic page's menu belong to another website")
+ # Except if the URL already exists for this website (its the case now that we already cloned it once)
+ Page.clone_page(self.page_1.id, clone_menu=True)
+ cloned_generic_page_2 = Page.search([('url', '=', '/page_1-1'), ('id', '!=', self.page_1.id)])
+ self.assertEqual(len(cloned_generic_page_2), 1, "A generic page being cloned should create a specific page with a new URL if there is already a specific page with that URL")
+
+ def test_cow_page(self):
+ Menu = self.env['website.menu']
+ Page = self.env['website.page']
+ View = self.env['ir.ui.view']
+
+ # backend write, no COW
+ total_pages = Page.search_count([])
+ total_menus = Menu.search_count([])
+ total_views = View.search_count([])
+ self.page_1.write({'arch': '<div>modified base content</div>'})
+ self.assertEqual(total_pages, Page.search_count([]))
+ self.assertEqual(total_menus, Menu.search_count([]))
+ self.assertEqual(total_views, View.search_count([]))
+
+ # edit through frontend
+ self.page_1.with_context(website_id=1).write({'arch': '<div>website 1 content</div>'})
+
+ # 1. should have created website-specific copies for:
+ # - page
+ # - view x2 (base view + extension view)
+ # 2. should not have created menu copy as menus are not shared/COW
+ # 3. and shouldn't have touched original records
+ self.assertEqual(total_pages + 1, Page.search_count([]))
+ self.assertEqual(total_menus, Menu.search_count([]))
+ self.assertEqual(total_views + 2, View.search_count([]))
+
+ self.assertEqual(self.page_1.arch, '<div>modified base content</div>')
+ self.assertEqual(bool(self.page_1.website_id), False)
+
+ new_page = Page.search([('url', '=', '/page_1'), ('id', '!=', self.page_1.id)])
+ self.assertEqual(new_page.website_id.id, 1)
+ self.assertEqual(new_page.view_id.inherit_children_ids[0].website_id.id, 1)
+ self.assertEqual(new_page.arch, '<div>website 1 content</div>')
+
+ def test_cow_extension_view(self):
+ ''' test cow on extension view itself (like web_editor would do in the frontend) '''
+ Menu = self.env['website.menu']
+ Page = self.env['website.page']
+ View = self.env['ir.ui.view']
+
+ # nothing special should happen when editing through the backend
+ total_pages = Page.search_count([])
+ total_menus = Menu.search_count([])
+ total_views = View.search_count([])
+ self.extension_view.write({'arch': '<div>modified extension content</div>'})
+ self.assertEqual(self.extension_view.arch, '<div>modified extension content</div>')
+ self.assertEqual(total_pages, Page.search_count([]))
+ self.assertEqual(total_menus, Menu.search_count([]))
+ self.assertEqual(total_views, View.search_count([]))
+
+ # When editing through the frontend a website-specific copy
+ # for the extension view should be created. When rendering the
+ # original website.page on website 1 it will look differently
+ # due to this new extension view.
+ self.extension_view.with_context(website_id=1).write({'arch': '<div>website 1 content</div>'})
+ self.assertEqual(total_pages, Page.search_count([]))
+ self.assertEqual(total_menus, Menu.search_count([]))
+ self.assertEqual(total_views + 1, View.search_count([]))
+
+ self.assertEqual(self.extension_view.arch, '<div>modified extension content</div>')
+ self.assertEqual(bool(self.page_1.website_id), False)
+
+ new_view = View.search([('name', '=', 'Extension'), ('website_id', '=', 1)])
+ self.assertEqual(new_view.arch, '<div>website 1 content</div>')
+ self.assertEqual(new_view.website_id.id, 1)
+
+ def test_cou_page_backend(self):
+ Page = self.env['website.page']
+ View = self.env['ir.ui.view']
+
+ # currently the view unlink of website.page can't handle views with inherited views
+ self.extension_view.unlink()
+
+ self.page_1.unlink()
+ self.assertEqual(Page.search_count([('url', '=', '/page_1')]), 0)
+ self.assertEqual(View.search_count([('name', 'in', ('Base', 'Extension'))]), 0)
+
+ def test_cou_page_frontend(self):
+ Page = self.env['website.page']
+ View = self.env['ir.ui.view']
+ Website = self.env['website']
+
+ website2 = self.env['website'].create({
+ 'name': 'My Second Website',
+ 'domain': '',
+ })
+
+ # currently the view unlink of website.page can't handle views with inherited views
+ self.extension_view.unlink()
+
+ website_id = 1
+ self.page_1.with_context(website_id=website_id).unlink()
+
+ self.assertEqual(bool(self.base_view.exists()), False)
+ self.assertEqual(bool(self.page_1.exists()), False)
+ # Not COU but deleting a page will delete its menu (cascade)
+ self.assertEqual(bool(self.page_1_menu.exists()), False)
+
+ pages = Page.search([('url', '=', '/page_1')])
+ self.assertEqual(len(pages), Website.search_count([]) - 1, "A specific page for every website should have been created, except for the one from where we deleted the generic one.")
+ self.assertTrue(website_id not in pages.mapped('website_id').ids, "The website from which we deleted the generic page should not have a specific one.")
+ self.assertTrue(website_id not in View.search([('name', 'in', ('Base', 'Extension'))]).mapped('website_id').ids, "Same for views")
+
+@tagged('-at_install', 'post_install')
+class WithContext(HttpCase):
+ def setUp(self):
+ super().setUp()
+ Page = self.env['website.page']
+ View = self.env['ir.ui.view']
+ base_view = View.create({
+ 'name': 'Base',
+ 'type': 'qweb',
+ 'arch': '''<t name="Homepage" t-name="website.base_view">
+ <t t-call="website.layout">
+ I am a generic page
+ </t>
+ </t>''',
+ 'key': 'test.base_view',
+ })
+ self.page = Page.create({
+ 'view_id': base_view.id,
+ 'url': '/page_1',
+ 'is_published': True,
+ })
+
+ def test_unpublished_page(self):
+ specific_page = self.page.copy({'website_id': self.env['website'].get_current_website().id})
+ specific_page.write({'is_published': False, 'arch': self.page.arch.replace('I am a generic page', 'I am a specific page')})
+
+ self.authenticate(None, None)
+ r = self.url_open(specific_page.url)
+ self.assertEqual(r.status_code, 404, "Restricted users should see a 404 and not the generic one as we unpublished the specific one")
+
+ self.authenticate('admin', 'admin')
+ r = self.url_open(specific_page.url)
+ self.assertEqual(r.status_code, 200, "Admin should see the specific unpublished page")
+ self.assertEqual('I am a specific page' in r.text, True, "Admin should see the specific unpublished page")
+
+ def test_search(self):
+ dbname = common.get_db_name()
+ admin_uid = self.env.ref('base.user_admin').id
+ website = self.env['website'].get_current_website()
+
+ robot = self.xmlrpc_object.execute(
+ dbname, admin_uid, 'admin',
+ 'website', 'search_pages', [website.id], 'info'
+ )
+ self.assertIn({'loc': '/website/info'}, robot)
+
+ pages = self.xmlrpc_object.execute(
+ dbname, admin_uid, 'admin',
+ 'website', 'search_pages', [website.id], 'page'
+ )
+ self.assertIn(
+ '/page_1',
+ [p['loc'] for p in pages],
+ )
diff --git a/addons/website/tests/test_performance.py b/addons/website/tests/test_performance.py
new file mode 100644
index 00000000..ad29f647
--- /dev/null
+++ b/addons/website/tests/test_performance.py
@@ -0,0 +1,122 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests.common import HttpCase
+
+EXTRA_REQUEST = 5
+""" During tests, the query on 'base_registry_signaling, base_cache_signaling'
+ won't be executed on hot state, but 6 new queries related to the test cursor
+ will be added:
+ SAVEPOINT "test_cursor_4"
+ SAVEPOINT "test_cursor_4"
+ ROLLBACK TO SAVEPOINT "test_cursor_4"
+ SAVEPOINT "test_cursor_5"
+ [.. usual SQL Queries .. ]
+ SAVEPOINT "test_cursor_5"
+ ROLLBACK TO SAVEPOINT "test_cursor_5"
+"""
+
+
+class UtilPerf(HttpCase):
+ def _get_url_hot_query(self, url):
+ url += ('?' not in url and '?' or '') + '&nocache'
+
+ # ensure worker is in hot state
+ self.url_open(url)
+ self.url_open(url)
+
+ sql_count = self.registry.test_cr.sql_log_count
+ self.url_open(url)
+ return self.registry.test_cr.sql_log_count - sql_count - EXTRA_REQUEST
+
+
+class TestStandardPerformance(UtilPerf):
+ def test_10_perf_sql_img_controller(self):
+ self.authenticate('demo', 'demo')
+ url = '/web/image/res.users/2/image_256'
+ self.assertEqual(self._get_url_hot_query(url), 7)
+
+ def test_20_perf_sql_img_controller_bis(self):
+ url = '/web/image/website/1/favicon'
+ self.assertEqual(self._get_url_hot_query(url), 4)
+ self.authenticate('portal', 'portal')
+ self.assertEqual(self._get_url_hot_query(url), 4)
+
+
+class TestWebsitePerformance(UtilPerf):
+
+ def setUp(self):
+ super().setUp()
+ self.page, self.menu = self._create_page_with_menu('/sql_page')
+
+ def _create_page_with_menu(self, url):
+ name = url[1:]
+ website = self.env['website'].browse(1)
+ page = self.env['website.page'].create({
+ 'url': url,
+ 'name': name,
+ 'type': 'qweb',
+ 'arch': '<t name="%s" t-name="website.page_test_%s"> \
+ <t t-call="website.layout"> \
+ <div id="wrap"><div class="oe_structure"/></div> \
+ </t> \
+ </t>' % (name, name),
+ 'key': 'website.page_test_%s' % name,
+ 'is_published': True,
+ 'website_id': website.id,
+ 'track': False,
+ })
+ menu = self.env['website.menu'].create({
+ 'name': name,
+ 'url': url,
+ 'page_id': page.id,
+ 'website_id': website.id,
+ 'parent_id': website.menu_id.id
+ })
+ return (page, menu)
+
+ def test_10_perf_sql_queries_page(self):
+ # standard untracked website.page
+ self.assertEqual(self._get_url_hot_query(self.page.url), 11)
+ self.menu.unlink()
+ self.assertEqual(self._get_url_hot_query(self.page.url), 13)
+
+ def test_15_perf_sql_queries_page(self):
+ # standard tracked website.page
+ self.page.track = True
+ self.assertEqual(self._get_url_hot_query(self.page.url), 19)
+ self.menu.unlink()
+ self.assertEqual(self._get_url_hot_query(self.page.url), 21)
+
+ def test_20_perf_sql_queries_homepage(self):
+ # homepage "/" has its own controller
+ self.assertEqual(self._get_url_hot_query('/'), 20)
+
+ def test_30_perf_sql_queries_page_no_layout(self):
+ # website.page with no call to layout templates
+ self.page.arch = '<div>I am a blank page</div>'
+ self.assertEqual(self._get_url_hot_query(self.page.url), 9)
+
+ def test_40_perf_sql_queries_page_multi_level_menu(self):
+ # menu structure should not impact SQL requests
+ _, menu_a = self._create_page_with_menu('/a')
+ _, menu_aa = self._create_page_with_menu('/aa')
+ _, menu_b = self._create_page_with_menu('/b')
+ _, menu_bb = self._create_page_with_menu('/bb')
+ _, menu_bbb = self._create_page_with_menu('/bbb')
+ _, menu_bbbb = self._create_page_with_menu('/bbbb')
+ _, menu_bbbbb = self._create_page_with_menu('/bbbbb')
+ self._create_page_with_menu('c')
+ menu_bbbbb.parent_id = menu_bbbb
+ menu_bbbb.parent_id = menu_bbb
+ menu_bbb.parent_id = menu_bb
+ menu_bb.parent_id = menu_b
+ menu_aa.parent_id = menu_a
+
+ self.assertEqual(self._get_url_hot_query(self.page.url), 11)
+
+ def test_50_perf_sql_web_content(self):
+ # assets route /web/content/..
+ self.url_open('/') # create assets attachments
+ assets_url = self.env['ir.attachment'].search([('url', '=like', '/web/content/%/web.assets_common%.js')], limit=1).url
+ self.assertEqual(self._get_url_hot_query(assets_url), 2)
diff --git a/addons/website/tests/test_qweb.py b/addons/website/tests/test_qweb.py
new file mode 100644
index 00000000..de87bd81
--- /dev/null
+++ b/addons/website/tests/test_qweb.py
@@ -0,0 +1,161 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from lxml import etree
+import re
+
+from odoo import http, tools
+from odoo.addons.base.tests.common import TransactionCaseWithUserDemo
+from odoo.addons.website.tools import MockRequest
+from odoo.modules.module import get_module_resource
+from odoo.tests.common import TransactionCase
+
+
+class TestQweb(TransactionCaseWithUserDemo):
+ def _load(self, module, *args):
+ tools.convert_file(self.cr, 'website',
+ get_module_resource(module, *args),
+ {}, 'init', False, 'test')
+
+ def test_qweb_cdn(self):
+ self._load('website', 'tests', 'template_qweb_test.xml')
+
+ website = self.env.ref('website.default_website')
+ website.write({
+ "cdn_activated": True,
+ "cdn_url": "http://test.cdn"
+ })
+
+ demo = self.env['res.users'].search([('login', '=', 'demo')])[0]
+ demo.write({"signature": '''<span class="toto">
+ span<span class="fa"></span><img src="/web/image/1"/>
+ </span>'''})
+
+ demo_env = self.env(user=demo)
+
+ html = demo_env['ir.qweb']._render('website.test_template', {"user": demo}, website_id= website.id)
+ asset_data = etree.HTML(html).xpath('//*[@data-asset-xmlid]')[0]
+ asset_xmlid = asset_data.attrib.get('data-asset-xmlid')
+ asset_version = asset_data.attrib.get('data-asset-version')
+
+ html = html.strip().decode('utf8')
+ html = re.sub(r'\?unique=[^"]+', '', html).encode('utf8')
+
+ attachments = demo_env['ir.attachment'].search([('url', '=like', '/web/content/%-%/website.test_bundle.%')])
+ self.assertEqual(len(attachments), 2)
+
+ format_data = {
+ "js": attachments[0].url,
+ "css": attachments[1].url,
+ "user_id": demo.id,
+ "filename": "Marc%20Demo",
+ "alt": "Marc Demo",
+ "asset_xmlid": asset_xmlid,
+ "asset_version": asset_version,
+ }
+
+ self.assertEqual(html, ("""<!DOCTYPE html>
+<html>
+ <head>
+ <link rel="stylesheet" href="http://test.external.link/style1.css"/>
+ <link rel="stylesheet" href="http://test.external.link/style2.css"/>
+ <link type="text/css" rel="stylesheet" href="http://test.cdn%(css)s" data-asset-xmlid="%(asset_xmlid)s" data-asset-version="%(asset_version)s"/>
+ <meta/>
+ <script type="text/javascript" src="http://test.external.link/javascript1.js"></script>
+ <script type="text/javascript" src="http://test.external.link/javascript2.js"></script>
+ <script type="text/javascript" src="http://test.cdn%(js)s" data-asset-xmlid="%(asset_xmlid)s" data-asset-version="%(asset_version)s"></script>
+ </head>
+ <body>
+ <img src="http://test.external.link/img.png" loading="lazy"/>
+ <img src="http://test.cdn/website/static/img.png" loading="lazy"/>
+ <a href="http://test.external.link/link">x</a>
+ <a href="http://test.cdn/web/content/local_link">x</a>
+ <span style="background-image: url('http://test.cdn/web/image/2')">xxx</span>
+ <div widget="html"><span class="toto">
+ span<span class="fa"></span><img src="http://test.cdn/web/image/1" loading="lazy">
+ </span></div>
+ <div widget="image"><img src="http://test.cdn/web/image/res.users/%(user_id)s/image_1920/%(filename)s" class="img img-fluid" alt="%(alt)s" loading="lazy"/></div>
+ </body>
+</html>""" % format_data).encode('utf8'))
+
+
+class TestQwebProcessAtt(TransactionCase):
+ def setUp(self):
+ super(TestQwebProcessAtt, self).setUp()
+ self.website = self.env.ref('website.default_website')
+ self.env['res.lang']._activate_lang('fr_FR')
+ self.website.language_ids = self.env.ref('base.lang_en') + self.env.ref('base.lang_fr')
+ self.website.default_lang_id = self.env.ref('base.lang_en')
+ self.website.cdn_activated = True
+ self.website.cdn_url = "http://test.cdn"
+ self.website.cdn_filters = "\n".join(["^(/[a-z]{2}_[A-Z]{2})?/a$", "^(/[a-z]{2})?/a$", "^/b$"])
+
+ def _test_att(self, url, expect, tag='a', attribute='href'):
+ self.assertEqual(
+ self.env['ir.qweb']._post_processing_att(tag, {attribute: url}, {}),
+ expect
+ )
+
+ def test_process_att_no_request(self):
+ # no request so no URL rewriting
+ self._test_att('/', {'href': '/'})
+ self._test_att('/en/', {'href': '/en/'})
+ self._test_att('/fr/', {'href': '/fr/'})
+ # no URL rewritting for CDN
+ self._test_att('/a', {'href': '/a'})
+
+ def test_process_att_no_website(self):
+ with MockRequest(self.env):
+ # no website so URL rewriting
+ self._test_att('/', {'href': '/'})
+ self._test_att('/en/', {'href': '/en/'})
+ self._test_att('/fr/', {'href': '/fr/'})
+ # no URL rewritting for CDN
+ self._test_att('/a', {'href': '/a'})
+
+ def test_process_att_monolang_route(self):
+ with MockRequest(self.env, website=self.website, multilang=False):
+ # lang not changed in URL but CDN enabled
+ self._test_att('/a', {'href': 'http://test.cdn/a'})
+ self._test_att('/en/a', {'href': 'http://test.cdn/en/a'})
+ self._test_att('/b', {'href': 'http://test.cdn/b'})
+ self._test_att('/en/b', {'href': '/en/b'})
+
+ def test_process_att_no_request_lang(self):
+ with MockRequest(self.env, website=self.website):
+ self._test_att('/', {'href': '/'})
+ self._test_att('/en/', {'href': '/'})
+ self._test_att('/fr/', {'href': '/fr/'})
+
+ def test_process_att_with_request_lang(self):
+ with MockRequest(self.env, website=self.website, context={'lang': 'fr_FR'}):
+ self._test_att('/', {'href': '/fr/'})
+ self._test_att('/en/', {'href': '/'})
+ self._test_att('/fr/', {'href': '/fr/'})
+
+ def test_process_att_matching_cdn_and_lang(self):
+ with MockRequest(self.env, website=self.website):
+ # lang prefix is added before CDN
+ self._test_att('/a', {'href': 'http://test.cdn/a'})
+ self._test_att('/en/a', {'href': 'http://test.cdn/a'})
+ self._test_att('/fr/a', {'href': 'http://test.cdn/fr/a'})
+ self._test_att('/b', {'href': 'http://test.cdn/b'})
+ self._test_att('/en/b', {'href': 'http://test.cdn/b'})
+ self._test_att('/fr/b', {'href': '/fr/b'})
+
+ def test_process_att_no_route(self):
+ with MockRequest(self.env, website=self.website, context={'lang': 'fr_FR'}, routing=False):
+ # default on multilang=True if route is not /{module}/static/
+ self._test_att('/web/static/hi', {'href': '/web/static/hi'})
+ self._test_att('/my-page', {'href': '/fr/my-page'})
+
+ def test_process_att_url_crap(self):
+ with MockRequest(self.env, website=self.website):
+ match = http.root.get_db_router.return_value.bind.return_value.match
+ # #{fragment} is stripped from URL when testing route
+ self._test_att('/x#y?z', {'href': '/x#y?z'})
+ match.assert_called_with('/x', method='POST', query_args=None)
+
+ match.reset_calls()
+ self._test_att('/x?y#z', {'href': '/x?y#z'})
+ match.assert_called_with('/x', method='POST', query_args='y')
diff --git a/addons/website/tests/test_res_users.py b/addons/website/tests/test_res_users.py
new file mode 100644
index 00000000..ef8d97ed
--- /dev/null
+++ b/addons/website/tests/test_res_users.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from psycopg2 import IntegrityError
+
+from odoo.tests.common import TransactionCase, new_test_user
+from odoo.exceptions import ValidationError
+from odoo.service.model import check
+from odoo.tools import mute_logger
+
+
+class TestWebsiteResUsers(TransactionCase):
+
+ def setUp(self):
+ super().setUp()
+ websites = self.env['website'].create([
+ {'name': 'Test Website'},
+ {'name': 'Test Website 2'},
+ ])
+ self.website_1, self.website_2 = websites
+
+ def test_no_website(self):
+ new_test_user(self.env, login='Pou', website_id=False)
+ with self.assertRaises(ValidationError):
+ new_test_user(self.env, login='Pou', website_id=False)
+
+ def test_websites_set_null(self):
+ user_1 = new_test_user(self.env, login='Pou', website_id=self.website_1.id)
+ user_2 = new_test_user(self.env, login='Pou', website_id=self.website_2.id)
+ with self.assertRaises(ValidationError):
+ (user_1 | user_2).write({'website_id': False})
+
+ def test_null_and_website(self):
+ new_test_user(self.env, login='Pou', website_id=self.website_1.id)
+ new_test_user(self.env, login='Pou', website_id=False)
+
+ def test_change_login(self):
+ new_test_user(self.env, login='Pou', website_id=self.website_1.id)
+ user_belle = new_test_user(self.env, login='Belle', website_id=self.website_1.id)
+ with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
+ user_belle.login = 'Pou'
+
+ def test_change_login_no_website(self):
+ new_test_user(self.env, login='Pou', website_id=False)
+ user_belle = new_test_user(self.env, login='Belle', website_id=False)
+ with self.assertRaises(ValidationError):
+ user_belle.login = 'Pou'
+
+ def test_same_website_message(self):
+
+ @check # Check decorator, otherwise translation is not applied
+ def check_new_test_user(dbname):
+ new_test_user(self.env(context={'land': 'en_US'}), login='Pou', website_id=self.website_1.id)
+
+ new_test_user(self.env, login='Pou', website_id=self.website_1.id)
+
+ # Should be a ValidationError (with a nice translated error message),
+ # not an IntegrityError
+ with self.assertRaises(ValidationError), mute_logger('odoo.sql_db'):
+ check_new_test_user(self.env.registry._db.dbname)
diff --git a/addons/website/tests/test_snippets.py b/addons/website/tests/test_snippets.py
new file mode 100644
index 00000000..83f906f5
--- /dev/null
+++ b/addons/website/tests/test_snippets.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import odoo
+import odoo.tests
+
+
+@odoo.tests.common.tagged('post_install', '-at_install', 'website_snippets')
+class TestSnippets(odoo.tests.HttpCase):
+
+ def test_01_empty_parents_autoremove(self):
+ self.start_tour("/?enable_editor=1", "snippet_empty_parent_autoremove", login='admin')
diff --git a/addons/website/tests/test_theme.py b/addons/website/tests/test_theme.py
new file mode 100644
index 00000000..845474d6
--- /dev/null
+++ b/addons/website/tests/test_theme.py
@@ -0,0 +1,14 @@
+# coding: utf-8
+from odoo.tests import common
+
+
+class TestTheme(common.TransactionCase):
+
+ def test_theme_remove_working(self):
+ """ This test ensure theme can be removed.
+ Theme removal is also the first step during theme installation.
+ """
+ theme_common_module = self.env['ir.module.module'].search([('name', '=', 'theme_default')])
+ website = self.env['website'].get_current_website()
+ website.theme_id = theme_common_module.id
+ self.env['ir.module.module']._theme_remove(website)
diff --git a/addons/website/tests/test_ui.py b/addons/website/tests/test_ui.py
new file mode 100644
index 00000000..1a629bc4
--- /dev/null
+++ b/addons/website/tests/test_ui.py
@@ -0,0 +1,194 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import odoo
+import odoo.tests
+
+
+@odoo.tests.tagged('-at_install', 'post_install')
+class TestUiCustomizeTheme(odoo.tests.HttpCase):
+ def test_01_attachment_website_unlink(self):
+ ''' Some ir.attachment needs to be unlinked when a website is unlink,
+ otherwise some flows will just crash. That's the case when 2 website
+ have their theme color customized. Removing a website will make its
+ customized attachment generic, thus having 2 attachments with the
+ same URL available for other websites, leading to singleton errors
+ (among other).
+
+ But no all attachment should be deleted, eg we don't want to delete
+ a SO or invoice PDF coming from an ecommerce order.
+ '''
+ Website = self.env['website']
+ Page = self.env['website.page']
+ Attachment = self.env['ir.attachment']
+
+ website_default = Website.browse(1)
+ website_test = Website.create({'name': 'Website Test'})
+
+ # simulate attachment state when editing 2 theme through customize
+ custom_url = '/TEST/website/static/src/scss/options/colors/user_theme_color_palette.custom.web.assets_common.scss'
+ scss_attachment = Attachment.create({
+ 'name': custom_url,
+ 'type': 'binary',
+ 'mimetype': 'text/scss',
+ 'datas': '',
+ 'url': custom_url,
+ 'website_id': website_default.id
+ })
+ scss_attachment.copy({'website_id': website_test.id})
+
+ # simulate PDF from ecommerce order
+ # Note: it will only have its website_id flag if the website has a domain
+ # equal to the current URL (fallback or get_current_website())
+ so_attachment = Attachment.create({
+ 'name': 'SO036.pdf',
+ 'type': 'binary',
+ 'mimetype': 'application/pdf',
+ 'datas': '',
+ 'website_id': website_test.id
+ })
+
+ # avoid sql error on page website_id restrict
+ Page.search([('website_id', '=', website_test.id)]).unlink()
+ website_test.unlink()
+ self.assertEqual(Attachment.search_count([('url', '=', custom_url)]), 1, 'Should not left duplicates when deleting a website')
+ self.assertTrue(so_attachment.exists(), 'Most attachment should not be deleted')
+ self.assertFalse(so_attachment.website_id, 'Website should be removed')
+
+
+@odoo.tests.tagged('-at_install', 'post_install')
+class TestUiHtmlEditor(odoo.tests.HttpCase):
+ def test_html_editor_multiple_templates(self):
+ Website = self.env['website']
+ View = self.env['ir.ui.view']
+ Page = self.env['website.page']
+
+ self.generic_view = View.create({
+ 'name': 'Generic',
+ 'type': 'qweb',
+ 'arch': '''
+ <div>content</div>
+ ''',
+ 'key': 'test.generic_view',
+ })
+
+ self.generic_page = Page.create({
+ 'view_id': self.generic_view.id,
+ 'url': '/generic',
+ })
+
+ generic_page = Website.viewref('test.generic_view')
+ # Use an empty page layout with oe_structure id for this test
+ oe_structure_layout = '''
+ <t name="Generic" t-name="test.generic_view">
+ <t t-call="website.layout">
+ <div id="oe_structure_test_ui" class="oe_structure oe_empty"/>
+ </t>
+ </t>
+ '''
+ generic_page.arch = oe_structure_layout
+ self.start_tour("/", 'html_editor_multiple_templates', login='admin')
+ self.assertEqual(View.search_count([('key', '=', 'test.generic_view')]), 2, "homepage view should have been COW'd")
+ self.assertTrue(generic_page.arch == oe_structure_layout, "Generic homepage view should be untouched")
+ self.assertEqual(len(generic_page.inherit_children_ids.filtered(lambda v: 'oe_structure' in v.name)), 0, "oe_structure view should have been deleted when aboutus was COW")
+ specific_page = Website.with_context(website_id=1).viewref('test.generic_view')
+ self.assertTrue(specific_page.arch != oe_structure_layout, "Specific homepage view should have been changed")
+ self.assertEqual(len(specific_page.inherit_children_ids.filtered(lambda v: 'oe_structure' in v.name)), 1, "oe_structure view should have been created on the specific tree")
+
+ def test_html_editor_scss(self):
+ self.start_tour("/", 'test_html_editor_scss', login='admin')
+
+@odoo.tests.tagged('-at_install', 'post_install')
+class TestUiTranslate(odoo.tests.HttpCase):
+ def test_admin_tour_rte_translator(self):
+ fr_BE = self.env.ref('base.lang_fr_BE')
+ fr_BE.active = True
+ self.env.ref('website.default_website').language_ids |= fr_BE
+ self.start_tour("/", 'rte_translator', login='admin', timeout=120)
+
+
+@odoo.tests.common.tagged('post_install', '-at_install')
+class TestUi(odoo.tests.HttpCase):
+
+ def test_01_admin_tour_homepage(self):
+ self.start_tour("/?enable_editor=1", 'homepage', login='admin')
+
+ def test_02_restricted_editor(self):
+ self.restricted_editor = self.env['res.users'].create({
+ 'name': 'Restricted Editor',
+ 'login': 'restricted',
+ 'password': 'restricted',
+ 'groups_id': [(6, 0, [
+ self.ref('base.group_user'),
+ self.ref('website.group_website_publisher')
+ ])]
+ })
+ self.start_tour("/", 'restricted_editor', login='restricted')
+
+ def test_03_backend_dashboard(self):
+ self.start_tour("/", 'backend_dashboard', login='admin')
+
+ def test_04_website_navbar_menu(self):
+ website = self.env['website'].search([], limit=1)
+ self.env['website.menu'].create({
+ 'name': 'Test Tour Menu',
+ 'url': '/test-tour-menu',
+ 'parent_id': website.menu_id.id,
+ 'sequence': 0,
+ 'website_id': website.id,
+ })
+ self.start_tour("/", 'website_navbar_menu')
+
+ def test_05_specific_website_editor(self):
+ website_default = self.env['website'].search([], limit=1)
+ new_website = self.env['website'].create({'name': 'New Website'})
+ website_editor_assets_view = self.env.ref('website.assets_wysiwyg')
+ self.env['ir.ui.view'].create({
+ 'name': 'Editor Extension',
+ 'type': 'qweb',
+ 'inherit_id': website_editor_assets_view.id,
+ 'website_id': new_website.id,
+ 'arch': """
+ <xpath expr="." position="inside">
+ <script type="text/javascript">document.body.dataset.hello = 'world';</script>
+ </xpath>
+ """,
+ })
+ self.start_tour("/website/force/%s" % website_default.id, "generic_website_editor", login='admin')
+ self.start_tour("/website/force/%s" % new_website.id, "specific_website_editor", login='admin')
+
+ def test_06_public_user_editor(self):
+ website_default = self.env['website'].search([], limit=1)
+ website_default.homepage_id.arch = """
+ <t name="Homepage" t-name="website.homepage">
+ <t t-call="website.layout">
+ <textarea class="o_public_user_editor_test_textarea o_wysiwyg_loader"/>
+ </t>
+ </t>
+ """
+ self.start_tour("/", "public_user_editor", login=None)
+
+ def test_07_snippet_version(self):
+ website_snippets = self.env.ref('website.snippets')
+ self.env['ir.ui.view'].create([{
+ 'name': 'Test snip',
+ 'type': 'qweb',
+ 'key': 'website.s_test_snip',
+ 'arch': """
+ <section class="s_test_snip">
+ <t t-snippet-call="website.s_share"/>
+ </section>
+ """,
+ }, {
+ 'type': 'qweb',
+ 'inherit_id': website_snippets.id,
+ 'arch': """
+ <xpath expr="//t[@t-snippet='website.s_parallax']" position="after">
+ <t t-snippet="website.s_test_snip" t-thumbnail="/website/static/src/img/snippets_thumbs/s_website_form.svg"/>
+ </xpath>
+ """,
+ }])
+ self.start_tour("/", 'snippet_version', login='admin')
+
+ def test_08_website_style_custo(self):
+ self.start_tour("/", "website_style_edition", login="admin")
diff --git a/addons/website/tests/test_views.py b/addons/website/tests/test_views.py
new file mode 100644
index 00000000..39c2ded1
--- /dev/null
+++ b/addons/website/tests/test_views.py
@@ -0,0 +1,1432 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import unittest
+from itertools import zip_longest
+from lxml import etree as ET, html
+from lxml.html import builder as h
+
+from odoo.tests import common, HttpCase, tagged
+
+
+def attrs(**kwargs):
+ return {'data-oe-%s' % key: str(value) for key, value in kwargs.items()}
+
+
+class TestViewSavingCommon(common.TransactionCase):
+ def _create_imd(self, view):
+ xml_id = view.key.split('.')
+ return self.env['ir.model.data'].create({
+ 'module': xml_id[0],
+ 'name': xml_id[1],
+ 'model': view._name,
+ 'res_id': view.id,
+ })
+
+
+class TestViewSaving(TestViewSavingCommon):
+
+ def eq(self, a, b):
+ self.assertEqual(a.tag, b.tag)
+ self.assertEqual(a.attrib, b.attrib)
+ self.assertEqual((a.text or '').strip(), (b.text or '').strip())
+ self.assertEqual((a.tail or '').strip(), (b.tail or '').strip())
+ for ca, cb in zip_longest(a, b):
+ self.eq(ca, cb)
+
+ def setUp(self):
+ super(TestViewSaving, self).setUp()
+ self.arch = h.DIV(
+ h.DIV(
+ h.H3("Column 1"),
+ h.UL(
+ h.LI("Item 1"),
+ h.LI("Item 2"),
+ h.LI("Item 3"))),
+ h.DIV(
+ h.H3("Column 2"),
+ h.UL(
+ h.LI("Item 1"),
+ h.LI(h.SPAN("My Company", attrs(model='res.company', id=1, field='name', type='char'))),
+ h.LI(h.SPAN("+00 00 000 00 0 000", attrs(model='res.company', id=1, field='phone', type='char')))
+ ))
+ )
+ self.view_id = self.env['ir.ui.view'].create({
+ 'name': "Test View",
+ 'type': 'qweb',
+ 'key': 'website.test_view',
+ 'arch': ET.tostring(self.arch, encoding='unicode')
+ })
+
+ def test_embedded_extraction(self):
+ fields = self.env['ir.ui.view'].extract_embedded_fields(self.arch)
+
+ expect = [
+ h.SPAN("My Company", attrs(model='res.company', id=1, field='name', type='char')),
+ h.SPAN("+00 00 000 00 0 000", attrs(model='res.company', id=1, field='phone', type='char')),
+ ]
+ for actual, expected in zip_longest(fields, expect):
+ self.eq(actual, expected)
+
+ def test_embedded_save(self):
+ embedded = h.SPAN("+00 00 000 00 0 000", attrs(
+ model='res.company', id=1, field='phone', type='char'))
+
+ self.env['ir.ui.view'].save_embedded_field(embedded)
+
+ company = self.env['res.company'].browse(1)
+ self.assertEqual(company.phone, "+00 00 000 00 0 000")
+
+ @unittest.skip("save conflict for embedded (saved by third party or previous version in page) not implemented")
+ def test_embedded_conflict(self):
+ e1 = h.SPAN("My Company", attrs(model='res.company', id=1, field='name'))
+ e2 = h.SPAN("Leeroy Jenkins", attrs(model='res.company', id=1, field='name'))
+
+ View = self.env['ir.ui.view']
+
+ View.save_embedded_field(e1)
+ # FIXME: more precise exception
+ with self.assertRaises(Exception):
+ View.save_embedded_field(e2)
+
+ def test_embedded_to_field_ref(self):
+ View = self.env['ir.ui.view']
+ embedded = h.SPAN("My Company", attrs(expression="bob"))
+ self.eq(
+ View.to_field_ref(embedded),
+ h.SPAN({'t-field': 'bob'})
+ )
+
+ def test_to_field_ref_keep_attributes(self):
+ View = self.env['ir.ui.view']
+
+ att = attrs(expression="bob", model="res.company", id=1, field="name")
+ att['id'] = "whop"
+ att['class'] = "foo bar"
+ embedded = h.SPAN("My Company", att)
+
+ self.eq(View.to_field_ref(embedded), h.SPAN({'t-field': 'bob', 'class': 'foo bar', 'id': 'whop'}))
+
+ def test_replace_arch(self):
+ replacement = h.P("Wheee")
+
+ result = self.view_id.replace_arch_section(None, replacement)
+
+ self.eq(result, h.DIV("Wheee"))
+
+ def test_replace_arch_2(self):
+ replacement = h.DIV(h.P("Wheee"))
+
+ result = self.view_id.replace_arch_section(None, replacement)
+
+ self.eq(result, replacement)
+
+ def test_fixup_arch(self):
+ replacement = h.H1("I am the greatest title alive!")
+
+ result = self.view_id.replace_arch_section('/div/div[1]/h3', replacement)
+
+ self.eq(result, h.DIV(
+ h.DIV(
+ h.H3("I am the greatest title alive!"),
+ h.UL(
+ h.LI("Item 1"),
+ h.LI("Item 2"),
+ h.LI("Item 3"))),
+ h.DIV(
+ h.H3("Column 2"),
+ h.UL(
+ h.LI("Item 1"),
+ h.LI(h.SPAN("My Company", attrs(model='res.company', id=1, field='name', type='char'))),
+ h.LI(h.SPAN("+00 00 000 00 0 000", attrs(model='res.company', id=1, field='phone', type='char')))
+ ))
+ ))
+
+ def test_multiple_xpath_matches(self):
+ with self.assertRaises(ValueError):
+ self.view_id.replace_arch_section('/div/div/h3', h.H6("Lol nope"))
+
+ def test_save(self):
+ Company = self.env['res.company']
+
+ # create an xmlid for the view
+ imd = self._create_imd(self.view_id)
+ self.assertEqual(self.view_id.model_data_id, imd)
+ self.assertFalse(imd.noupdate)
+
+ replacement = ET.tostring(h.DIV(
+ h.H3("Column 2"),
+ h.UL(
+ h.LI("wob wob wob"),
+ h.LI(h.SPAN("Acme Corporation", attrs(model='res.company', id=1, field='name', expression="bob", type='char'))),
+ h.LI(h.SPAN("+12 3456789", attrs(model='res.company', id=1, field='phone', expression="edmund", type='char'))),
+ )
+ ), encoding='unicode')
+
+ self.view_id.with_context(website_id=1).save(value=replacement, xpath='/div/div[2]')
+ self.assertFalse(imd.noupdate, "view's xml_id shouldn't be set to 'noupdate' in a website context as `save` method will COW")
+ # remove newly created COW view so next `save()`` wont be redirected to COW view
+ self.env['website'].with_context(website_id=1).viewref(self.view_id.key).unlink()
+
+ self.view_id.save(value=replacement, xpath='/div/div[2]')
+
+ # the xml_id of the view should be flagged as 'noupdate'
+ self.assertTrue(imd.noupdate)
+
+ company = Company.browse(1)
+ self.assertEqual(company.name, "Acme Corporation")
+ self.assertEqual(company.phone, "+12 3456789")
+ self.eq(
+ ET.fromstring(self.view_id.arch),
+ h.DIV(
+ h.DIV(
+ h.H3("Column 1"),
+ h.UL(
+ h.LI("Item 1"),
+ h.LI("Item 2"),
+ h.LI("Item 3"))),
+ h.DIV(
+ h.H3("Column 2"),
+ h.UL(
+ h.LI("wob wob wob"),
+ h.LI(h.SPAN({'t-field': "bob"})),
+ h.LI(h.SPAN({'t-field': "edmund"}))
+ ))
+ )
+ )
+
+ def test_save_escaped_text(self):
+ """ Test saving html special chars in text nodes """
+ view = self.env['ir.ui.view'].create({
+ 'arch': u'<t t-name="dummy"><p><h1>hello world</h1></p></t>',
+ 'type': 'qweb'
+ })
+ # script and style text nodes should not escaped client side
+ replacement = u'<script>1 && "hello & world"</script>'
+ view.save(replacement, xpath='/t/p/h1')
+ self.assertIn(
+ replacement.replace(u'&', u'&amp;'),
+ view.arch,
+ 'inline script should be escaped server side'
+ )
+ self.assertIn(
+ replacement,
+ view._render().decode('utf-8'),
+ 'inline script should not be escaped when rendering'
+ )
+ # common text nodes should be be escaped client side
+ replacement = u'world &amp;amp; &amp;lt;b&amp;gt;cie'
+ view.save(replacement, xpath='/t/p')
+ self.assertIn(replacement, view.arch, 'common text node should not be escaped server side')
+ self.assertIn(
+ replacement,
+ view._render().decode('utf-8').replace(u'&', u'&amp;'),
+ 'text node characters wrongly unescaped when rendering'
+ )
+
+ def test_save_oe_structure_with_attr(self):
+ """ Test saving oe_structure with attributes """
+ view = self.env['ir.ui.view'].create({
+ 'arch': u'<t t-name="dummy"><div class="oe_structure" t-att-test="1" data-test="1" id="oe_structure_test"/></t>',
+ 'type': 'qweb'
+ }).with_context(website_id=1, load_all_views=True)
+ replacement = u'<div class="oe_structure" data-test="1" id="oe_structure_test" data-oe-id="55" test="2">hello</div>'
+ view.save(replacement, xpath='/t/div')
+ # branding data-oe-* should be stripped
+ self.assertIn(
+ '<div class="oe_structure" data-test="1" id="oe_structure_test" test="2">hello</div>',
+ view.read_combined(['arch'])['arch'],
+ 'saved element attributes are saved excluding branding ones'
+ )
+
+ def test_save_only_embedded(self):
+ Company = self.env['res.company']
+ company_id = 1
+ company = Company.browse(company_id)
+ company.write({'name': "Foo Corporation"})
+
+ node = html.tostring(h.SPAN(
+ "Acme Corporation",
+ attrs(model='res.company', id=company_id, field="name", expression='bob', type='char')),
+ encoding='unicode')
+ View = self.env['ir.ui.view']
+ View.browse(company_id).save(value=node)
+ self.assertEqual(company.name, "Acme Corporation")
+
+ def test_field_tail(self):
+ replacement = ET.tostring(
+ h.LI(h.SPAN("+12 3456789", attrs(
+ model='res.company', id=1, type='char',
+ field='phone', expression="edmund")),
+ "whop whop"
+ ), encoding="utf-8")
+ self.view_id.save(value=replacement, xpath='/div/div[2]/ul/li[3]')
+
+ self.eq(
+ ET.fromstring(self.view_id.arch.encode('utf-8')),
+ h.DIV(
+ h.DIV(
+ h.H3("Column 1"),
+ h.UL(
+ h.LI("Item 1"),
+ h.LI("Item 2"),
+ h.LI("Item 3"))),
+ h.DIV(
+ h.H3("Column 2"),
+ h.UL(
+ h.LI("Item 1"),
+ h.LI(h.SPAN("My Company", attrs(model='res.company', id=1, field='name', type='char'))),
+ h.LI(h.SPAN({'t-field': "edmund"}), "whop whop"),
+ ))
+ )
+ )
+
+
+@tagged('-at_install', 'post_install')
+class TestCowViewSaving(TestViewSavingCommon):
+ def setUp(self):
+ super(TestCowViewSaving, self).setUp()
+ View = self.env['ir.ui.view']
+
+ self.base_view = View.create({
+ 'name': 'Base',
+ 'type': 'qweb',
+ 'arch': '<div>base content</div>',
+ 'key': 'website.base_view',
+ }).with_context(load_all_views=True)
+
+ self.inherit_view = View.create({
+ 'name': 'Extension',
+ 'mode': 'extension',
+ 'inherit_id': self.base_view.id,
+ 'arch': '<div position="inside">, extended content</div>',
+ 'key': 'website.extension_view',
+ })
+
+ def test_cow_on_base_after_extension(self):
+ View = self.env['ir.ui.view']
+ self.inherit_view.with_context(website_id=1).write({'name': 'Extension Specific'})
+ v1 = self.base_view
+ v2 = self.inherit_view
+ v3 = View.search([('website_id', '=', 1), ('name', '=', 'Extension Specific')])
+ v4 = self.inherit_view.copy({'name': 'Second Extension'})
+ v5 = self.inherit_view.copy({'name': 'Third Extension (Specific)'})
+ v5.write({'website_id': 1})
+
+ # id | name | website_id | inherit | key
+ # ------------------------------------------------------------------------
+ # 1 | Base | / | / | website.base_view
+ # 2 | Extension | / | 1 | website.extension_view
+ # 3 | Extension Specific | 1 | 1 | website.extension_view
+ # 4 | Second Extension | / | 1 | website.extension_view_a5f579d5 (generated hash)
+ # 5 | Third Extension (Specific) | 1 | 1 | website.extension_view_5gr87e6c (another generated hash)
+
+ self.assertEqual(v2.key == v3.key, True, "Making specific a generic inherited view should copy it's key (just change the website_id)")
+ self.assertEqual(v3.key != v4.key != v5.key, True, "Copying a view should generate a new key for the new view (not the case when triggering COW)")
+ self.assertEqual('website.extension_view' in v3.key and 'website.extension_view' in v4.key and 'website.extension_view' in v5.key, True, "The copied views should have the key from the view it was copied from but with an unique suffix")
+
+ total_views = View.search_count([])
+ v1.with_context(website_id=1).write({'name': 'Base Specific'})
+
+ # id | name | website_id | inherit | key
+ # ------------------------------------------------------------------------
+ # 1 | Base | / | / | website.base_view
+ # 2 | Extension | / | 1 | website.extension_view
+ # 3 - DELETED
+ # 4 | Second Extension | / | 1 | website.extension_view_a5f579d5
+ # 5 - DELETED
+ # 6 | Base Specific | 1 | / | website.base_view
+ # 7 | Extension Specific | 1 | 6 | website.extension_view
+ # 8 | Second Extension | 1 | 6 | website.extension_view_a5f579d5
+ # 9 | Third Extension (Specific) | 1 | 6 | website.extension_view_5gr87e6c
+
+ v6 = View.search([('website_id', '=', 1), ('name', '=', 'Base Specific')])
+ v7 = View.search([('website_id', '=', 1), ('name', '=', 'Extension Specific')])
+ v8 = View.search([('website_id', '=', 1), ('name', '=', 'Second Extension')])
+ v9 = View.search([('website_id', '=', 1), ('name', '=', 'Third Extension (Specific)')])
+
+ self.assertEqual(total_views + 4 - 2, View.search_count([]), "It should have duplicated the view tree with a website_id, taking only most specific (only specific `b` key), and removing website_specific from generic tree")
+ self.assertEqual(len((v3 + v5).exists()), 0, "v3 and v5 should have been deleted as they were already specific and copied to the new specific base")
+ # Check generic tree
+ self.assertEqual((v1 + v2 + v4).mapped('website_id').ids, [])
+ self.assertEqual((v2 + v4).mapped('inherit_id'), v1)
+ # Check specific tree
+ self.assertEqual((v6 + v7 + v8 + v9).mapped('website_id').ids, [1])
+ self.assertEqual((v7 + v8 + v9).mapped('inherit_id'), v6)
+ # Check key
+ self.assertEqual(v6.key == v1.key, True)
+ self.assertEqual(v7.key == v2.key, True)
+ self.assertEqual(v4.key == v8.key, True)
+ self.assertEqual(View.search_count([('key', '=', v9.key)]), 1)
+
+ def test_cow_leaf(self):
+ View = self.env['ir.ui.view']
+
+ # edit on backend, regular write
+ self.inherit_view.write({'arch': '<div position="replace"><div>modified content</div></div>'})
+ self.assertEqual(View.search_count([('key', '=', 'website.base_view')]), 1)
+ self.assertEqual(View.search_count([('key', '=', 'website.extension_view')]), 1)
+
+ arch = self.base_view.read_combined(['arch'])['arch']
+ self.assertEqual(arch, '<div>modified content</div>')
+
+ # edit on frontend, copy just the leaf
+ self.inherit_view.with_context(website_id=1).write({'arch': '<div position="replace"><div>website 1 content</div></div>'})
+ inherit_views = View.search([('key', '=', 'website.extension_view')])
+ self.assertEqual(View.search_count([('key', '=', 'website.base_view')]), 1)
+ self.assertEqual(len(inherit_views), 2)
+ self.assertEqual(len(inherit_views.filtered(lambda v: v.website_id.id == 1)), 1)
+
+ # read in backend should be unaffected
+ arch = self.base_view.read_combined(['arch'])['arch']
+ self.assertEqual(arch, '<div>modified content</div>')
+ # read on website should reflect change
+ arch = self.base_view.with_context(website_id=1).read_combined(['arch'])['arch']
+ self.assertEqual(arch, '<div>website 1 content</div>')
+
+ # website-specific inactive view should take preference over active generic one when viewing the website
+ # this is necessary to make customize_show=True templates work correctly
+ inherit_views.filtered(lambda v: v.website_id.id == 1).write({'active': False})
+ arch = self.base_view.with_context(website_id=1).read_combined(['arch'])['arch']
+ self.assertEqual(arch, '<div>base content</div>')
+
+ def test_cow_root(self):
+ View = self.env['ir.ui.view']
+
+ # edit on backend, regular write
+ self.base_view.write({'arch': '<div>modified base content</div>'})
+ self.assertEqual(View.search_count([('key', '=', 'website.base_view')]), 1)
+ self.assertEqual(View.search_count([('key', '=', 'website.extension_view')]), 1)
+
+ # edit on frontend, copy the entire tree
+ self.base_view.with_context(website_id=1).write({'arch': '<div>website 1 content</div>'})
+
+ generic_base_view = View.search([('key', '=', 'website.base_view'), ('website_id', '=', False)])
+ website_specific_base_view = View.search([('key', '=', 'website.base_view'), ('website_id', '=', 1)])
+ self.assertEqual(len(generic_base_view), 1)
+ self.assertEqual(len(website_specific_base_view), 1)
+
+ inherit_views = View.search([('key', '=', 'website.extension_view')])
+ self.assertEqual(len(inherit_views), 2)
+ self.assertEqual(len(inherit_views.filtered(lambda v: v.website_id.id == 1)), 1)
+
+ arch = generic_base_view.with_context(load_all_views=True).read_combined(['arch'])['arch']
+ self.assertEqual(arch, '<div>modified base content, extended content</div>')
+
+ arch = website_specific_base_view.with_context(load_all_views=True, website_id=1).read_combined(['arch'])['arch']
+ self.assertEqual(arch, '<div>website 1 content, extended content</div>')
+
+ # # As there is a new SQL constraint that prevent QWeb views to have an empty `key`, this test won't work
+ # def test_cow_view_without_key(self):
+ # # Remove key for this test
+ # self.base_view.key = False
+ #
+ # View = self.env['ir.ui.view']
+ #
+ # # edit on backend, regular write
+ # self.base_view.write({'arch': '<div>modified base content</div>'})
+ # self.assertEqual(self.base_view.key, False, "Writing on a keyless view should not set a key on it if there is no website in context")
+ #
+ # # edit on frontend, copy just the leaf
+ # self.base_view.with_context(website_id=1).write({'arch': '<div position="replace"><div>website 1 content</div></div>'})
+ # self.assertEqual('website.key_' in self.base_view.key, True, "Writing on a keyless view should set a key on it if there is a website in context")
+ # total_views_with_key = View.search_count([('key', '=', self.base_view.key)])
+ # self.assertEqual(total_views_with_key, 2, "It should have set the key on generic view then copy to specific view (with they key)")
+
+ def test_cow_generic_view_with_already_existing_specific(self):
+ """ Writing on a generic view should check if a website specific view already exists
+ (The flow of this test will happen when editing a generic view in the front end and changing more than one element)
+ """
+ # 1. Test with calling write directly
+ View = self.env['ir.ui.view']
+
+ base_view = View.create({
+ 'name': 'Base',
+ 'type': 'qweb',
+ 'arch': '<div>content</div>',
+ })
+
+ total_views = View.with_context(active_test=False).search_count([])
+ base_view.with_context(website_id=1).write({'name': 'New Name'}) # This will not write on `base_view` but will copy it to a specific view on which the `name` change will be applied
+ specific_view = View.search([['name', '=', 'New Name'], ['website_id', '=', 1]])
+ base_view.with_context(website_id=1).write({'name': 'Another New Name'})
+ specific_view.active = False
+ base_view.with_context(website_id=1).write({'name': 'Yet Another New Name'})
+ self.assertEqual(total_views + 1, View.with_context(active_test=False).search_count([]), "Subsequent writes should have written on the view copied during first write")
+
+ # 2. Test with calling save() from ir.ui.view
+ view_arch = '''<t name="Second View" t-name="website.second_view">
+ <t t-call="website.layout">
+ <div id="wrap">
+ <div class="editable_part"/>
+ <div class="container">
+ <h1>Second View</h1>
+ </div>
+ <div class="editable_part"/>
+ </div>
+ </t>
+ </t>'''
+ second_view = View.create({
+ 'name': 'Base',
+ 'type': 'qweb',
+ 'arch': view_arch,
+ })
+
+ total_views = View.with_context(active_test=False).search_count([])
+ second_view.with_context(website_id=1).save('<div class="editable_part" data-oe-id="%s" data-oe-xpath="/t[1]/t[1]/div[1]/div[1]" data-oe-field="arch" data-oe-model="ir.ui.view">First editable_part</div>' % second_view.id, "/t[1]/t[1]/div[1]/div[1]")
+ second_view.with_context(website_id=1).save('<div class="editable_part" data-oe-id="%s" data-oe-xpath="/t[1]/t[1]/div[1]/div[3]" data-oe-field="arch" data-oe-model="ir.ui.view">Second editable_part</div>' % second_view.id, "/t[1]/t[1]/div[1]/div[3]")
+ self.assertEqual(total_views + 1, View.with_context(active_test=False).search_count([]), "Second save should have written on the view copied during first save")
+
+ total_specific_view = View.with_context(active_test=False).search_count([('arch_db', 'like', 'First editable_part'), ('arch_db', 'like', 'Second editable_part')])
+ self.assertEqual(total_specific_view, 1, "both editable_part should have been replaced on a created specific view")
+
+ def test_cow_complete_flow(self):
+ View = self.env['ir.ui.view']
+ total_views = View.search_count([])
+
+ self.base_view.write({'arch': '<div>Hi</div>'})
+ self.inherit_view.write({'arch': '<div position="inside"> World</div>'})
+
+ # id | name | content | website_id | inherit | key
+ # -------------------------------------------------------
+ # 1 | Base | Hi | / | / | website.base_view
+ # 2 | Extension | World | / | 1 | website.extension_view
+
+ arch = self.base_view.with_context(website_id=1).read_combined(['arch'])['arch']
+ self.assertEqual('Hi World' in arch, True)
+
+ self.base_view.write({'arch': '<div>Hello</div>'})
+
+ # id | name | content | website_id | inherit | key
+ # -------------------------------------------------------
+ # 1 | Base | Hello | / | / | website.base_view
+ # 2 | Extension | World | / | 1 | website.extension_view
+
+ arch = self.base_view.with_context(website_id=1).read_combined(['arch'])['arch']
+ self.assertEqual('Hello World' in arch, True)
+
+ self.base_view.with_context(website_id=1).write({'arch': '<div>Bye</div>'})
+
+ # id | name | content | website_id | inherit | key
+ # -------------------------------------------------------
+ # 1 | Base | Hello | / | / | website.base_view
+ # 3 | Base | Bye | 1 | / | website.base_view
+ # 2 | Extension | World | / | 1 | website.extension_view
+ # 4 | Extension | World | 1 | 3 | website.extension_view
+
+ base_specific = View.search([('key', '=', self.base_view.key), ('website_id', '=', 1)]).with_context(load_all_views=True)
+ extend_specific = View.search([('key', '=', self.inherit_view.key), ('website_id', '=', 1)])
+ self.assertEqual(total_views + 2, View.search_count([]), "Should have copied Base & Extension with a website_id")
+ self.assertEqual(self.base_view.key, base_specific.key)
+ self.assertEqual(self.inherit_view.key, extend_specific.key)
+
+ extend_specific.write({'arch': '<div position="inside"> All</div>'})
+
+ # id | name | content | website_id | inherit | key
+ # -------------------------------------------------------
+ # 1 | Base | Hello | / | / | website.base_view
+ # 3 | Base | Bye | 1 | / | website.base_view
+ # 2 | Extension | World | / | 1 | website.extension_view
+ # 4 | Extension | All | 1 | 3 | website.extension_view
+
+ arch = base_specific.with_context(website_id=1).read_combined(['arch'])['arch']
+ self.assertEqual('Bye All' in arch, True)
+
+ self.inherit_view.with_context(website_id=1).write({'arch': '<div position="inside"> Nobody</div>'})
+
+ # id | name | content | website_id | inherit | key
+ # -------------------------------------------------------
+ # 1 | Base | Hello | / | / | website.base_view
+ # 3 | Base | Bye | 1 | / | website.base_view
+ # 2 | Extension | World | / | 1 | website.extension_view
+ # 4 | Extension | Nobody | 1 | 3 | website.extension_view
+
+ arch = base_specific.with_context(website_id=1).read_combined(['arch'])['arch']
+ self.assertEqual('Bye Nobody' in arch, True, "Write on generic `inherit_view` should have been diverted to already existing specific view")
+
+ base_arch = self.base_view.read_combined(['arch'])['arch']
+ base_arch_w1 = self.base_view.with_context(website_id=1).read_combined(['arch'])['arch']
+ self.assertEqual('Hello World' in base_arch, True)
+ self.assertEqual(base_arch, base_arch_w1, "Reading a top level view with or without a website_id in the context should render that exact view..") # ..even if there is a specific view for that one, as read_combined is supposed to render specific inherited view over generic but not specific top level instead of generic top level
+
+ def test_cow_cross_inherit(self):
+ View = self.env['ir.ui.view']
+ total_views = View.search_count([])
+
+ main_view = View.create({
+ 'name': 'Main View',
+ 'type': 'qweb',
+ 'arch': '<body>GENERIC<div>A</div></body>',
+ 'key': 'website.main_view',
+ }).with_context(load_all_views=True)
+
+ View.create({
+ 'name': 'Child View',
+ 'mode': 'extension',
+ 'inherit_id': main_view.id,
+ 'arch': '<xpath expr="//div" position="replace"><div>VIEW<p>B</p></div></xpath>',
+ 'key': 'website.child_view',
+ })
+
+ child_view_2 = View.with_context(load_all_views=True).create({
+ 'name': 'Child View 2',
+ 'mode': 'extension',
+ 'inherit_id': main_view.id,
+ 'arch': '<xpath expr="//p" position="replace"><span>C</span></xpath>',
+ 'key': 'website.child_view_2',
+ })
+
+ # These line doing `write()` are the real tests, it should not be changed and should not crash on xpath.
+ child_view_2.with_context(website_id=1).write({'arch': '<xpath expr="//p" position="replace"><span>D</span></xpath>'})
+ self.assertEqual(total_views + 3 + 1, View.search_count([]), "It should have created the 3 initial generic views and created a child_view_2 specific view")
+ main_view.with_context(website_id=1).write({'arch': '<body>SPECIFIC<div>Z</div></body>'})
+ self.assertEqual(total_views + 3 + 3, View.search_count([]), "It should have duplicated the Main View tree as a specific tree and then removed the specific view from the generic tree as no more needed")
+
+ generic_view = View.with_context(website_id=None).get_view_id('website.main_view')
+ specific_view = View.with_context(website_id=1).get_view_id('website.main_view')
+ generic_view_arch = View.browse(generic_view).with_context(load_all_views=True).read_combined(['arch'])['arch']
+ specific_view_arch = View.browse(specific_view).with_context(load_all_views=True, website_id=1).read_combined(['arch'])['arch']
+ self.assertEqual(generic_view_arch, '<body>GENERIC<div>VIEW<span>C</span></div></body>')
+ self.assertEqual(specific_view_arch, '<body>SPECIFIC<div>VIEW<span>D</span></div></body>', "Writing on top level view hierarchy with a website in context should write on the view and clone it's inherited views")
+
+ def test_multi_website_view_obj_active(self):
+ ''' With the following structure:
+ * A generic active parent view
+ * A generic active child view, that is inactive on website 1
+ The methods to retrieve views should return the specific inactive
+ child over the generic active one.
+ '''
+ View = self.env['ir.ui.view']
+ self.inherit_view.with_context(website_id=1).write({'active': False})
+
+ # Test _view_obj() return the inactive specific over active generic
+ inherit_view = View._view_obj(self.inherit_view.key)
+ self.assertEqual(inherit_view.active, True, "_view_obj should return the generic one")
+ inherit_view = View.with_context(website_id=1)._view_obj(self.inherit_view.key)
+ self.assertEqual(inherit_view.active, False, "_view_obj should return the specific one")
+
+ # Test get_related_views() return the inactive specific over active generic
+ # Note that we cannot test get_related_views without a website in context as it will fallback on a website with get_current_website()
+ views = View.with_context(website_id=1).get_related_views(self.base_view.key)
+ self.assertEqual(views.mapped('active'), [True, False], "get_related_views should return the specific child")
+
+ # Test filter_duplicate() return the inactive specific over active generic
+ view = View.with_context(active_test=False).search([('key', '=', self.inherit_view.key)]).filter_duplicate()
+ self.assertEqual(view.active, True, "filter_duplicate should return the generic one")
+ view = View.with_context(active_test=False, website_id=1).search([('key', '=', self.inherit_view.key)]).filter_duplicate()
+ self.assertEqual(view.active, False, "filter_duplicate should return the specific one")
+
+ def test_get_related_views_tree(self):
+ View = self.env['ir.ui.view']
+
+ self.base_view.write({'name': 'B', 'key': 'B'})
+ self.inherit_view.write({'name': 'I', 'key': 'I'})
+ View.create({
+ 'name': 'II',
+ 'mode': 'extension',
+ 'inherit_id': self.inherit_view.id,
+ 'arch': '<div position="inside">, sub ext</div>',
+ 'key': 'II',
+ })
+
+ # B
+ # |
+ # I
+ # |
+ # II
+
+ # First, test that children of inactive children are not returned (not multiwebsite related)
+ self.inherit_view.active = False
+ views = View.get_related_views('B')
+ self.assertEqual(views.mapped('key'), ['B', 'I'], "As 'I' is inactive, 'II' (its own child) should not be returned.")
+ self.inherit_view.active = True
+
+ # Second, test multi-website
+ self.inherit_view.with_context(website_id=1).write({'name': 'Extension'}) # Trigger cow on hierarchy
+ View.create({
+ 'name': 'II2',
+ 'mode': 'extension',
+ 'inherit_id': self.inherit_view.id,
+ 'arch': '<div position="inside">, sub sibling specific</div>',
+ 'key': 'II2',
+ })
+
+ # B
+ # / \
+ # / \
+ # I I'
+ # / \ |
+ # II II2 II'
+
+ views = View.with_context(website_id=1).get_related_views('B')
+ self.assertEqual(views.mapped('key'), ['B', 'I', 'II'], "Should only return the specific tree")
+
+ def test_get_related_views_tree_recursive_t_call_and_inherit_inactive(self):
+ """ If a view A was doing a t-call on a view B and view B had view C as child.
+ And view A had view D as child.
+ And view D also t-call view B (that as mentionned above has view C as child).
+ And view D was inactive (`d` in bellow schema).
+
+ Then COWing C to set it as inactive would make `get_related_views()` on A to return
+ both generic active C and COW inactive C.
+ (Typically the case for Customize show on /shop for Wishlist, compare..)
+ See commit message for detailed explanation.
+ """
+ # A -> B
+ # | ^ \
+ # | | C
+ # d ___|
+
+ View = self.env['ir.ui.view']
+ Website = self.env['website']
+
+ products = View.create({
+ 'name': 'Products',
+ 'type': 'qweb',
+ 'key': '_website_sale.products',
+ 'arch': '''
+ <div id="products_grid">
+ <t t-call="_website_sale.products_item"/>
+ </div>
+ ''',
+ })
+
+ products_item = View.create({
+ 'name': 'Products item',
+ 'type': 'qweb',
+ 'key': '_website_sale.products_item',
+ 'arch': '''
+ <div class="product_price"/>
+ ''',
+ })
+
+ add_to_wishlist = View.create({
+ 'name': 'Wishlist',
+ 'active': True,
+ 'customize_show': True,
+ 'inherit_id': products_item.id,
+ 'key': '_website_sale_wishlist.add_to_wishlist',
+ 'arch': '''
+ <xpath expr="//div[hasclass('product_price')]" position="inside"></xpath>
+ ''',
+ })
+
+ products_list_view = View.create({
+ 'name': 'List View',
+ 'active': False, # <- That's the reason of why this behavior needed a fix
+ 'customize_show': True,
+ 'inherit_id': products.id,
+ 'key': '_website_sale.products_list_view',
+ 'arch': '''
+ <div id="products_grid" position="replace">
+ <t t-call="_website_sale.products_item"/>
+ </div>
+ ''',
+ })
+
+ views = View.with_context(website_id=1).get_related_views('_website_sale.products')
+ self.assertEqual(views, products + products_item + add_to_wishlist + products_list_view, "The four views should be returned.")
+ add_to_wishlist.with_context(website_id=1).write({'active': False}) # Trigger cow on hierarchy
+ add_to_wishlist_cow = Website.with_context(website_id=1).viewref(add_to_wishlist.key)
+ views = View.with_context(website_id=1).get_related_views('_website_sale.products')
+ self.assertEqual(views, products + products_item + add_to_wishlist_cow + products_list_view, "The generic wishlist view should have been replaced by the COW one.")
+
+ def test_cow_inherit_children_order(self):
+ """ COW method should loop on inherit_children_ids in correct order
+ when copying them on the new specific tree.
+ Correct order is the same as the one when applying view arch:
+ PRIORITY, ID
+ And not the default one from ir.ui.view (NAME, PRIORIRTY, ID).
+ """
+ self.inherit_view.copy({
+ 'name': 'alphabetically before "Extension"',
+ 'key': '_test.alphabetically_first',
+ 'arch': '<div position="replace"><p>COMPARE</p></div>',
+ })
+ # Next line should not crash, COW loop on inherit_children_ids should be sorted correctly
+ self.base_view.with_context(website_id=1).write({'name': 'Product (W1)'})
+
+ def test_module_new_inherit_view_on_parent_already_forked(self):
+ """ If a generic parent view is copied (COW) and that another module
+ creates a child view for that generic parent, all the COW views
+ should also get a copy of that new child view.
+
+ Typically, a parent view (website_sale.product) is copied (COW)
+ and then wishlist module is installed.
+ Wishlist views inhering from website_sale.product are added to the
+ generic `website_sale.product`. But it should also be added to the
+ COW `website_sale.product` to activate the module views for that
+ website.
+ """
+ Website = self.env['website']
+ View = self.env['ir.ui.view']
+
+ # Simulate website_sale product view
+ self.base_view.write({'name': 'Product', 'key': '_website_sale.product'})
+ # Trigger cow on website_sale hierarchy for website 1
+ self.base_view.with_context(website_id=1).write({'name': 'Product (W1)'})
+
+ # Simulate website_sale_comparison install
+ View._load_records([dict(xml_id='_website_sale_comparison.product_add_to_compare', values={
+ 'name': 'Add to comparison in product page',
+ 'mode': 'extension',
+ 'inherit_id': self.base_view.id,
+ 'arch': '<div position="replace"><p>COMPARE</p></div>',
+ 'key': '_website_sale_comparison.product_add_to_compare',
+ })])
+ Website.with_context(load_all_views=True).viewref('_website_sale_comparison.product_add_to_compare').invalidate_cache()
+
+ # Simulate end of installation/update
+ View._create_all_specific_views(['_website_sale_comparison'])
+
+ specific_view = Website.with_context(load_all_views=True, website_id=1).viewref('_website_sale.product')
+ self.assertEqual(self.base_view.key, specific_view.key, "Ensure it is equal as it should be for the rest of the test so we test the expected behaviors")
+ specific_view_arch = specific_view.read_combined(['arch'])['arch']
+ self.assertEqual(specific_view.website_id.id, 1, "Ensure we got specific view to perform the checks against")
+ self.assertEqual(specific_view_arch, '<p>COMPARE</p>', "When a module creates an inherited view (on a generic tree), it should also create that view in the specific COW'd tree.")
+
+ # Simulate website_sale_comparison update
+ View._load_records([dict(xml_id='_website_sale_comparison.product_add_to_compare', values={
+ 'arch': '<div position="replace"><p>COMPARE EDITED</p></div>',
+ })])
+ specific_view_arch = Website.with_context(load_all_views=True, website_id=1).viewref('_website_sale.product').read_combined(['arch'])['arch']
+ self.assertEqual(specific_view_arch, '<p>COMPARE EDITED</p>', "When a module updates an inherited view (on a generic tree), it should also update the copies of that view (COW).")
+
+ # Test fields that should not be COW'd
+ random_views = View.search([('key', '!=', None)], limit=2)
+ View._load_records([dict(xml_id='_website_sale_comparison.product_add_to_compare', values={
+ 'website_id': None,
+ 'inherit_id': random_views[0].id,
+ })])
+
+ w1_specific_child_view = Website.with_context(load_all_views=True, website_id=1).viewref('_website_sale_comparison.product_add_to_compare')
+ generic_child_view = Website.with_context(load_all_views=True).viewref('_website_sale_comparison.product_add_to_compare')
+ self.assertEqual(w1_specific_child_view.website_id.id, 1, "website_id is a prohibited field when COWing views during _load_records")
+ self.assertEqual(generic_child_view.inherit_id, random_views[0], "prohibited fields only concerned write on COW'd view. Generic should still considere these fields")
+ self.assertEqual(w1_specific_child_view.inherit_id, random_views[0], "inherit_id update should be repliacated on cow views during _load_records")
+
+ # Set back the generic view as parent for the rest of the test
+ generic_child_view.inherit_id = self.base_view
+ w1_specific_child_view.inherit_id = specific_view
+
+ # Don't update inherit_id if it was anually updated
+ w1_specific_child_view.inherit_id = random_views[1].id
+ View._load_records([dict(xml_id='_website_sale_comparison.product_add_to_compare', values={
+ 'inherit_id': random_views[0].id,
+ })])
+ self.assertEqual(w1_specific_child_view.inherit_id, random_views[1],
+ "inherit_id update should not be repliacated on cow views during _load_records if it was manually updated before")
+
+ # Set back the generic view as parent for the rest of the test
+ generic_child_view.inherit_id = self.base_view
+ w1_specific_child_view.inherit_id = specific_view
+
+ # Don't update fields from COW'd view if these fields have been modified from original view
+ new_website = Website.create({'name': 'New Website'})
+ self.base_view.with_context(website_id=new_website.id).write({'name': 'Product (new_website)'})
+ new_website_specific_child_view = Website.with_context(load_all_views=True, website_id=new_website.id).viewref('_website_sale_comparison.product_add_to_compare')
+ new_website_specific_child_view.priority = 6
+ View._load_records([dict(xml_id='_website_sale_comparison.product_add_to_compare', values={
+ 'priority': 3,
+ })])
+ self.assertEqual(generic_child_view.priority, 3, "XML update should be written on the Generic View")
+ self.assertEqual(w1_specific_child_view.priority, 3, "XML update should be written on the specific view if the fields have not been modified on that specific view")
+ self.assertEqual(new_website_specific_child_view.priority, 6, "XML update should NOT be written on the specific view if the fields have been modified on that specific view")
+
+ # Simulate website_sale update on top level view
+ self._create_imd(self.base_view)
+ self.base_view.invalidate_cache()
+ View._load_records([dict(xml_id='_website_sale.product', values={
+ 'website_meta_title': 'A bug got fixed by updating this field',
+ })])
+ all_title_updated = specific_view.website_meta_title == self.base_view.website_meta_title == "A bug got fixed by updating this field"
+ self.assertEqual(all_title_updated, True, "Update on top level generic views should also be applied on specific views")
+
+ def test_module_new_inherit_view_on_parent_already_forked_xpath_replace(self):
+ """ Deeper, more specific test of above behavior.
+ A module install should add/update the COW view (if allowed fields,
+ eg not modified or prohibited (website_id, inherit_id..)).
+ This test ensure it does not crash if the child view is a primary view.
+ """
+ View = self.env['ir.ui.view']
+
+ # Simulate layout views
+ base_view = View.create({
+ 'name': 'Main Frontend Layout',
+ 'type': 'qweb',
+ 'arch': '<t t-call="web.layout"><t t-set="head_website"/></t>',
+ 'key': '_portal.frontend_layout',
+ }).with_context(load_all_views=True)
+
+ inherit_view = View.create({
+ 'name': 'Main layout',
+ 'mode': 'extension',
+ 'inherit_id': base_view.id,
+ 'arch': '<xpath expr="//t[@t-set=\'head_website\']" position="replace"><t t-call-assets="web_editor.assets_summernote" t-js="false" groups="website.group_website_publisher"/></xpath>',
+ 'key': '_website.layout',
+ })
+
+ # Trigger cow on website_sale hierarchy for website 1
+ base_view.with_context(website_id=1).write({'name': 'Main Frontend Layout (W1)'})
+
+ # Simulate website_sale_comparison install, that's the real test, it
+ # should not crash.
+ View._load_records([dict(xml_id='_website_forum.layout', values={
+ 'name': 'Forum Layout',
+ 'mode': 'primary',
+ 'inherit_id': inherit_view.id,
+ 'arch': '<xpath expr="//t[@t-call-assets=\'web_editor.assets_summernote\'][@t-js=\'false\']" position="attributes"><attribute name="groups"/></xpath>',
+ 'key': '_website_forum.layout',
+ })])
+
+ def test_multiple_inherit_level(self):
+ """ Test multi-level inheritance:
+ Base
+ |
+ ---> Extension (Website-specific)
+ |
+ ---> Extension 2 (Website-specific)
+ """
+ View = self.env['ir.ui.view']
+
+ self.inherit_view.website_id = 1
+ inherit_view_2 = View.create({
+ 'name': 'Extension 2',
+ 'mode': 'extension',
+ 'inherit_id': self.inherit_view.id,
+ 'arch': '<div position="inside">, extended content 2</div>',
+ 'key': 'website.extension_view_2',
+ 'website_id': 1,
+ })
+
+ total_views = View.search_count([])
+
+ # id | name | content | website_id | inherit | key
+ # --------------------------------------------------------------------------------------------
+ # 1 | Base | base content | / | / | website.base_view
+ # 2 | Extension | , extended content | 1 | 1 | website.extension_view
+ # 3 | Extension 2 | , extended content 2 | 1 | 2 | website.extension_view_2
+
+ self.base_view.with_context(website_id=1).write({'arch': '<div>modified content</div>'})
+
+ # 2 views are created, one is deleted
+ self.assertEqual(View.search_count([]), total_views + 1)
+ self.assertFalse(self.inherit_view.exists())
+ self.assertTrue(inherit_view_2.exists())
+
+ # Verify the inheritance
+ base_specific = View.search([('key', '=', self.base_view.key), ('website_id', '=', 1)]).with_context(load_all_views=True)
+ extend_specific = View.search([('key', '=', 'website.extension_view'), ('website_id', '=', 1)])
+ self.assertEqual(extend_specific.inherit_id, base_specific)
+ self.assertEqual(inherit_view_2.inherit_id, extend_specific)
+
+ # id | name | content | website_id | inherit | key
+ # --------------------------------------------------------------------------------------------
+ # 1 | Base | base content | / | / | website.base_view
+ # 4 | Base | modified content | 1 | / | website.base_view
+ # 5 | Extension | , extended content | 1 | 4 | website.extension_view
+ # 3 | Extension 2 | , extended content 2 | 1 | 5 | website.extension_view_2
+
+ def test_cow_extension_with_install(self):
+ View = self.env['ir.ui.view']
+ # Base
+ v1 = View.create({
+ 'name': 'Base',
+ 'type': 'qweb',
+ 'arch': '<div>base content</div>',
+ 'key': 'website.base_view_v1',
+ }).with_context(load_all_views=True)
+ self._create_imd(v1)
+
+ # Extension
+ v2 = View.create({
+ 'name': 'Extension',
+ 'mode': 'extension',
+ 'inherit_id': v1.id,
+ 'arch': '<div position="inside"><ooo>extended content</ooo></div>',
+ 'key': 'website.extension_view_v2',
+ })
+ self._create_imd(v2)
+
+ # multiwebsite specific
+ v1.with_context(website_id=1).write({'name': 'Extension Specific'})
+
+ original_pool_init = View.pool._init
+ View.pool._init = True
+
+ try:
+ # Simulate module install
+ View._load_records([dict(xml_id='website.extension2_view', values={
+ 'name': ' ---',
+ 'mode': 'extension',
+ 'inherit_id': v1.id,
+ 'arch': '<ooo position="replace"><p>EXTENSION</p></ooo>',
+ 'key': 'website.extension2_view',
+ })])
+ finally:
+ View.pool._init = original_pool_init
+
+ def test_specific_view_translation(self):
+ Translation = self.env['ir.translation']
+
+ Translation.insert_missing(self.base_view._fields['arch_db'], self.base_view)
+ translation = Translation.search([
+ ('res_id', '=', self.base_view.id), ('name', '=', 'ir.ui.view,arch_db')
+ ])
+ translation.value = 'hello'
+ translation.module = 'website'
+
+ self.base_view.with_context(website_id=1).write({'active': True})
+ specific_view = self.base_view._get_specific_views() - self.base_view
+
+ self.assertEqual(specific_view.with_context(lang='en_US').arch, '<div>hello</div>',
+ "copy on write (COW) also copy existing translations")
+
+ translation.value = 'hi'
+ self.assertEqual(specific_view.with_context(lang='en_US').arch, '<div>hello</div>',
+ "updating translation of base view doesn't update specific view")
+
+ Translation._load_module_terms(['website'], ['en_US'], overwrite=True)
+
+ specific_view.invalidate_cache(['arch_db', 'arch'])
+ self.assertEqual(specific_view.with_context(lang='en_US').arch, '<div>hi</div>',
+ "loading module translation copy translation from base to specific view")
+
+ def test_specific_view_module_update_inherit_change(self):
+ """ During a module update, if inherit_id is changed, we need to
+ replicate the change for cow views. """
+ # If D.inherit_id becomes B instead of A, after module update, we expect:
+ # CASE 1
+ # A A' B A A' B
+ # | | => / \
+ # D D' D D'
+ #
+ # CASE 2
+ # A A' B B' A A' B B'
+ # | | => | |
+ # D D' D D'
+ #
+ # CASE 3
+ # A B A B
+ # / \ => / \
+ # D D' D D'
+ #
+ # CASE 4
+ # A B B' A B B'
+ # / \ => | |
+ # D D' D D'
+
+ # 1. Setup following view trees
+ # A A' B
+ # | |
+ # D D'
+ View = self.env['ir.ui.view']
+ Website = self.env['website']
+ self._create_imd(self.inherit_view)
+ # invalidate cache to recompute xml_id, or it will still be empty
+ self.inherit_view.invalidate_cache()
+ base_view_2 = self.base_view.copy({'key': 'website.base_view2', 'arch': '<div>base2 content</div>'})
+ self.base_view.with_context(website_id=1).write({'arch': '<div>website 1 content</div>'})
+ specific_view = Website.with_context(load_all_views=True, website_id=1).viewref(self.base_view.key)
+ specific_view.inherit_children_ids.with_context(website_id=1).write({'arch': '<div position="inside">, extended content website 1</div>'})
+ specific_child_view = Website.with_context(load_all_views=True, website_id=1).viewref(self.inherit_view.key)
+ # 2. Ensure view trees are as expected
+ self.assertEqual(self.base_view.inherit_children_ids, self.inherit_view, "D should be under A")
+ self.assertEqual(specific_view.inherit_children_ids, specific_child_view, "D' should be under A'")
+ self.assertFalse(base_view_2.inherit_children_ids, "B should have no child")
+
+ # 3. Simulate module update, D.inherit_id is now B instead of A
+ View._load_records([dict(xml_id=self.inherit_view.key, values={
+ 'inherit_id': base_view_2.id,
+ })])
+
+ # 4. Ensure view trees is now
+ # A A' B
+ # / \
+ # D D'
+ self.assertTrue(len(self.base_view.inherit_children_ids) == len(specific_view.inherit_children_ids) == 0,
+ "Child views should now be under view B")
+ self.assertEqual(len(base_view_2.inherit_children_ids), 2, "D and D' should be under B")
+ self.assertTrue(self.inherit_view in base_view_2.inherit_children_ids, "D should be under B")
+ self.assertTrue(specific_child_view in base_view_2.inherit_children_ids, "D' should be under B")
+
+
+@tagged('-at_install', 'post_install')
+class Crawler(HttpCase):
+ def setUp(self):
+ super(Crawler, self).setUp()
+ View = self.env['ir.ui.view']
+
+ self.base_view = View.create({
+ 'name': 'Base',
+ 'type': 'qweb',
+ 'arch': '<div>base content</div>',
+ 'key': 'website.base_view',
+ }).with_context(load_all_views=True)
+
+ self.inherit_view = View.create({
+ 'name': 'Extension',
+ 'mode': 'extension',
+ 'inherit_id': self.base_view.id,
+ 'arch': '<div position="inside">, extended content</div>',
+ 'key': 'website.extension_view',
+ })
+
+ def test_get_switchable_related_views(self):
+ View = self.env['ir.ui.view']
+ Website = self.env['website']
+
+ # Set up
+ website_1 = Website.create({'name': 'Website 1'}) # will have specific views
+ website_2 = Website.create({'name': 'Website 2'}) # will use generic views
+
+ self.base_view.write({'name': 'Main Frontend Layout', 'key': '_portal.frontend_layout'})
+ event_main_view = self.base_view.copy({
+ 'name': 'Events',
+ 'key': '_website_event.index',
+ 'arch': '<t t-call="_website.layout"><div>Arch is not important in this test</div></t>',
+ })
+ self.inherit_view.write({'name': 'Main layout', 'key': '_website.layout'})
+
+ self.inherit_view.copy({'name': 'Sign In', 'customize_show': True, 'key': '_portal.user_sign_in'})
+ view_logo = self.inherit_view.copy({
+ 'name': 'Show Logo',
+ 'inherit_id': self.inherit_view.id,
+ 'customize_show': True,
+ 'key': '_website.layout_logo_show',
+ })
+ view_logo.copy({'name': 'Affix Top Menu', 'key': '_website.affix_top_menu'})
+
+ event_child_view = self.inherit_view.copy({
+ 'name': 'Filters',
+ 'customize_show': True,
+ 'inherit_id': event_main_view.id,
+ 'key': '_website_event.event_left_column',
+ 'priority': 30,
+ })
+ view_photos = event_child_view.copy({'name': 'Photos', 'key': '_website_event.event_right_photos'})
+ event_child_view.copy({'name': 'Quotes', 'key': '_website_event.event_right_quotes', 'priority': 30})
+
+ event_child_view.copy({'name': 'Filter by Category', 'inherit_id': event_child_view.id, 'key': '_website_event.event_category'})
+ event_child_view.copy({'name': 'Filter by Country', 'inherit_id': event_child_view.id, 'key': '_website_event.event_location'})
+
+ View.flush()
+
+ # Customize
+ # | Main Frontend Layout
+ # | Show Sign In
+ # | Main Layout
+ # | Affix Top Menu
+ # | Show Logo
+ # | Events
+ # | Filters
+ # | Photos
+ # | Quotes
+ # | Filters
+ # | Filter By Category
+ # | Filter By Country
+
+ self.authenticate("admin", "admin")
+ base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+
+ # Simulate website 2 (that use only generic views)
+ self.url_open(base_url + '/website/force/%s' % website_2.id)
+
+ # Test controller
+ url = base_url + '/website/get_switchable_related_views'
+ json = {'params': {'key': '_website_event.index'}}
+ response = self.opener.post(url=url, json=json)
+ res = response.json()['result']
+
+ self.assertEqual(
+ [v['name'] for v in res],
+ ['Sign In', 'Affix Top Menu', 'Show Logo', 'Filters', 'Photos', 'Quotes', 'Filter by Category', 'Filter by Country'],
+ "Sequence should not be taken into account for customize menu",
+ )
+ self.assertEqual(
+ [v['inherit_id'][1] for v in res],
+ ['Main Frontend Layout', 'Main layout', 'Main layout', 'Events', 'Events', 'Events', 'Filters', 'Filters'],
+ "Sequence should not be taken into account for customize menu (Checking Customize headers)",
+ )
+
+ # Trigger COW
+ view_logo.with_context(website_id=website_1.id).write({'arch': '<div position="inside">, trigger COW, arch is not relevant in this test</div>'})
+ # This would wrongly become:
+
+ # Customize
+ # | Main Frontend Layout
+ # | Show Sign In
+ # | Main Layout
+ # | Affix Top Menu
+ # | Show Logo <==== Was above "Affix Top Menu"
+ # | Events
+ # | Filters
+ # | Photos
+ # | Quotes
+ # | Filters
+ # | Filter By Category
+ # | Filter By Country
+
+ # Simulate website 1 (that has specific views)
+ self.url_open(base_url + '/website/force/%s' % website_1.id)
+
+ # Test controller
+ url = base_url + '/website/get_switchable_related_views'
+ json = {'params': {'key': '_website_event.index'}}
+ response = self.opener.post(url=url, json=json)
+ res = response.json()['result']
+ self.assertEqual(
+ [v['name'] for v in res],
+ ['Sign In', 'Affix Top Menu', 'Show Logo', 'Filters', 'Photos', 'Quotes', 'Filter by Category', 'Filter by Country'],
+ "multi-website COW should not impact customize views order (COW view will have a bigger ID and should not be last)",
+ )
+ self.assertEqual(
+ [v['inherit_id'][1] for v in res],
+ ['Main Frontend Layout', 'Main layout', 'Main layout', 'Events', 'Events', 'Events', 'Filters', 'Filters'],
+ "multi-website COW should not impact customize views menu header position or split (COW view will have a bigger ID and should not be last)",
+ )
+
+ # Trigger COW
+ view_photos.with_context(website_id=website_1.id).write({'arch': '<div position="inside">, trigger COW, arch is not relevant in this test</div>'})
+ # This would wrongly become:
+
+ # Customize
+ # | Main Frontend Layout
+ # | Show Sign In
+ # | Main Layout
+ # | Affix Top Menu
+ # | Show Logo
+ # | Events
+ # | Filters
+ # | Quotes
+ # | Filters
+ # | Filter By Category
+ # | Filter By Country
+ # | Events <==== JS code creates a new Events header as the Event's children views are not one after the other anymore..
+ # | Photos <==== .. since Photos got duplicated and now have a bigger ID that others
+
+ # Test controller
+ url = base_url + '/website/get_switchable_related_views'
+ json = {'params': {'key': '_website_event.index'}}
+ response = self.opener.post(url=url, json=json)
+ res = response.json()['result']
+ self.assertEqual(
+ [v['name'] for v in res],
+ ['Sign In', 'Affix Top Menu', 'Show Logo', 'Filters', 'Photos', 'Quotes', 'Filter by Category', 'Filter by Country'],
+ "multi-website COW should not impact customize views order (COW view will have a bigger ID and should not be last) (2)",
+ )
+ self.assertEqual(
+ [v['inherit_id'][1] for v in res],
+ ['Main Frontend Layout', 'Main layout', 'Main layout', 'Events', 'Events', 'Events', 'Filters', 'Filters'],
+ "multi-website COW should not impact customize views menu header position or split (COW view will have a bigger ID and should not be last) (2)",
+ )
+
+ def test_multi_website_views_retrieving(self):
+ View = self.env['ir.ui.view']
+ Website = self.env['website']
+
+ website_1 = Website.create({'name': 'Website 1'})
+ website_2 = Website.create({'name': 'Website 2'})
+
+ main_view = View.create({
+ 'name': 'Products',
+ 'type': 'qweb',
+ 'arch': '<body>Arch is not relevant for this test</body>',
+ 'key': '_website_sale.products',
+ }).with_context(load_all_views=True)
+
+ View.with_context(load_all_views=True).create({
+ 'name': 'Child View W1',
+ 'mode': 'extension',
+ 'inherit_id': main_view.id,
+ 'arch': '<xpath expr="//body" position="replace">It is really not relevant!</xpath>',
+ 'key': '_website_sale.child_view_w1',
+ 'website_id': website_1.id,
+ 'active': False,
+ 'customize_show': True,
+ })
+
+ # Simulate theme view instal + load on website
+ theme_view = self.env['theme.ir.ui.view'].with_context(install_filename='/testviews').create({
+ 'name': 'Products Theme Kea',
+ 'mode': 'extension',
+ 'inherit_id': main_view,
+ 'arch': '<xpath expr="//p" position="replace"><span>C</span></xpath>',
+ 'key': '_theme_kea_sale.products',
+ })
+ view_from_theme_view_on_w2 = View.with_context(load_all_views=True).create({
+ 'name': 'Products Theme Kea',
+ 'mode': 'extension',
+ 'inherit_id': main_view.id,
+ 'arch': '<xpath expr="//body" position="replace">Really really not important for this test</xpath>',
+ 'key': '_theme_kea_sale.products',
+ 'website_id': website_2.id,
+ 'customize_show': True,
+ })
+ self.env['ir.model.data'].create({
+ 'module': '_theme_kea_sale',
+ 'name': 'products',
+ 'model': 'theme.ir.ui.view',
+ 'res_id': theme_view.id,
+ })
+
+ # ##################################################### ir.ui.view ###############################################
+ # id | name | website_id | inherit | key | xml_id |
+ # ----------------------------------------------------------------------------------------------------------------
+ # 1 | Products | / | / | _website_sale.products | / |
+ # 2 | Child View W1 | 1 | 1 | _website_sale.child_view_w1 | / |
+ # 3 | Products Theme Kea | 2 | 1 | _theme_kea_sale.products | / |
+
+ # ################################################# theme.ir.ui.view #############################################
+ # id | name | inherit | key | xml_id |
+ # ----------------------------------------------------------------------------------------------------------------
+ # 1 | Products Theme Kea | 1 | _theme_kea_sale.products | _theme_kea_sale.products |
+
+ with self.assertRaises(ValueError):
+ # It should crash as it should not find a view on website 1 for '_theme_kea_sale.products', !!and certainly not a theme.ir.ui.view!!.
+ view = View.with_context(website_id=website_1.id)._view_obj('_theme_kea_sale.products')
+ view = View.with_context(website_id=website_2.id)._view_obj('_theme_kea_sale.products')
+ self.assertEqual(len(view), 1, "It should find the ir.ui.view with key '_theme_kea_sale.products' on website 2..")
+ self.assertEqual(view._name, 'ir.ui.view', "..and not a theme.ir.ui.view")
+
+ views = View.with_context(website_id=website_1.id).get_related_views('_website_sale.products')
+ self.assertEqual(len(views), 2, "It should not mix apples and oranges, only ir.ui.view ['_website_sale.products', '_website_sale.child_view_w1'] should be returned")
+ views = View.with_context(website_id=website_2.id).get_related_views('_website_sale.products')
+ self.assertEqual(len(views), 2, "It should not mix apples and oranges, only ir.ui.view ['_website_sale.products', '_theme_kea_sale.products'] should be returned")
+
+ # Part 2 of the test, it test the same stuff but from a higher level (get_related_views ends up calling _view_obj)
+ called_theme_view = self.env['theme.ir.ui.view'].with_context(install_filename='/testviews').create({
+ 'name': 'Called View Kea',
+ 'arch': '<div></div>',
+ 'key': '_theme_kea_sale.t_called_view',
+ })
+ View.create({
+ 'name': 'Called View Kea',
+ 'type': 'qweb',
+ 'arch': '<div></div>',
+ 'key': '_theme_kea_sale.t_called_view',
+ 'website_id': website_2.id,
+ }).with_context(load_all_views=True)
+ self.env['ir.model.data'].create({
+ 'module': '_theme_kea_sale',
+ 'name': 't_called_view',
+ 'model': 'theme.ir.ui.view',
+ 'res_id': called_theme_view.id,
+ })
+ view_from_theme_view_on_w2.write({'arch': '<t t-call="_theme_kea_sale.t_called_view"/>'})
+
+ # ##################################################### ir.ui.view ###############################################
+ # id | name | website_id | inherit | key | xml_id |
+ # ----------------------------------------------------------------------------------------------------------------
+ # 1 | Products | / | / | _website_sale.products | / |
+ # 2 | Child View W1 | 1 | 1 | _website_sale.child_view_w1 | / |
+ # 3 | Products Theme Kea | 2 | 1 | _theme_kea_sale.products | / |
+ # 4 | Called View Kea | 2 | / | _theme_kea_sale.t_called_view | / |
+
+ # ################################################# theme.ir.ui.view #############################################
+ # id | name | inherit | key | xml_id |
+ # ----------------------------------------------------------------------------------------------------------------
+ # 1 | Products Theme Kea | 1 | _theme_kea_sale.products | _theme_kea_sale.products |
+ # 1 | Called View Kea | / | _theme_kea_sale.t_called_view | _theme_kea_sale.t_called_view |
+
+ # Next line should not crash (was mixing apples and oranges - ir.ui.view and theme.ir.ui.view)
+ views = View.with_context(website_id=website_1.id).get_related_views('_website_sale.products')
+ self.assertEqual(len(views), 2, "It should not mix apples and oranges, only ir.ui.view ['_website_sale.products', '_website_sale.child_view_w1'] should be returned (2)")
+ views = View.with_context(website_id=website_2.id).get_related_views('_website_sale.products')
+ self.assertEqual(len(views), 3, "It should not mix apples and oranges, only ir.ui.view ['_website_sale.products', '_theme_kea_sale.products', '_theme_kea_sale.t_called_view'] should be returned")
+
+ # ########################################################
+ # Test the controller (which is calling get_related_views)
+ self.authenticate("admin", "admin")
+ base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+
+ # Simulate website 2
+ self.url_open(base_url + '/website/force/%s' % website_2.id)
+
+ # Test controller
+ url = base_url + '/website/get_switchable_related_views'
+ json = {'params': {'key': '_website_sale.products'}}
+ response = self.opener.post(url=url, json=json)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(response.json()['result']), 1, "Only '_theme_kea_sale.products' should be returned as it is the only customize_show related view in website 2 context")
+ self.assertEqual(response.json()['result'][0]['key'], '_theme_kea_sale.products', "Only '_theme_kea_sale.products' should be returned")
+
+ # Simulate website 1
+ self.url_open(base_url + '/website/force/%s' % website_1.id)
+
+ # Test controller
+ url = base_url + '/website/get_switchable_related_views'
+ json = {'params': {'key': '_website_sale.products'}}
+ response = self.opener.post(url=url, json=json)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(response.json()['result']), 1, "Only '_website_sale.child_view_w1' should be returned as it is the only customize_show related view in website 1 context")
+ self.assertEqual(response.json()['result'][0]['key'], '_website_sale.child_view_w1', "Only '_website_sale.child_view_w1' should be returned")
+
+
+@tagged('post_install', '-at_install')
+class TestThemeViews(common.TransactionCase):
+ def test_inherit_specific(self):
+ View = self.env['ir.ui.view']
+ Website = self.env['website']
+
+ website_1 = Website.create({'name': 'Website 1'})
+
+ # 1. Simulate COW structure
+ main_view = View.create({
+ 'name': 'Test Main View',
+ 'type': 'qweb',
+ 'arch': '<body>Arch is not relevant for this test</body>',
+ 'key': '_test.main_view',
+ }).with_context(load_all_views=True)
+ # Trigger COW
+ main_view.with_context(website_id=website_1.id).arch = '<body>specific</body>'
+
+ # 2. Simulate a theme install with a child view of `main_view`
+ test_theme_module = self.env['ir.module.module'].create({'name': 'test_theme'})
+ self.env['ir.model.data'].create({
+ 'module': 'base',
+ 'name': 'module_test_theme_module',
+ 'model': 'ir.module.module',
+ 'res_id': test_theme_module.id,
+ })
+ theme_view = self.env['theme.ir.ui.view'].with_context(install_filename='/testviews').create({
+ 'name': 'Test Child View',
+ 'mode': 'extension',
+ 'inherit_id': 'ir.ui.view,%s' % main_view.id,
+ 'arch': '<xpath expr="//body" position="replace"><span>C</span></xpath>',
+ 'key': 'test_theme.test_child_view',
+ })
+ self.env['ir.model.data'].create({
+ 'module': 'test_theme',
+ 'name': 'products',
+ 'model': 'theme.ir.ui.view',
+ 'res_id': theme_view.id,
+ })
+ test_theme_module.with_context(load_all_views=True)._theme_load(website_1)
+
+ # 3. Ensure everything went correctly
+ main_views = View.search([('key', '=', '_test.main_view')])
+ self.assertEqual(len(main_views), 2, "View should have been COWd when writing on its arch in a website context")
+ specific_main_view = main_views.filtered(lambda v: v.website_id == website_1)
+ specific_main_view_children = specific_main_view.inherit_children_ids
+ self.assertEqual(specific_main_view_children.name, 'Test Child View', "Ensure theme.ir.ui.view has been loaded as an ir.ui.view into the website..")
+ self.assertEqual(specific_main_view_children.website_id, website_1, "..and the website is the correct one.")
+
+ # 4. Simulate theme update. Do it 2 time to make sure it was not interpreted as a user change the first time.
+ new_arch = '<xpath expr="//body" position="replace"><span>Odoo Change01</span></xpath>'
+ theme_view.arch = new_arch
+ test_theme_module.with_context(load_all_views=True)._theme_load(website_1)
+ self.assertEqual(specific_main_view_children.arch, new_arch, "First time: View arch should receive theme updates.")
+ self.assertFalse(specific_main_view_children.arch_updated)
+ new_arch = '<xpath expr="//body" position="replace"><span>Odoo Change02</span></xpath>'
+ theme_view.arch = new_arch
+ test_theme_module.with_context(load_all_views=True)._theme_load(website_1)
+ self.assertEqual(specific_main_view_children.arch, new_arch, "Second time: View arch should still receive theme updates.")
+
+ # 5. Keep User arch changes
+ new_arch = '<xpath expr="//body" position="replace"><span>Odoo</span></xpath>'
+ specific_main_view_children.arch = new_arch
+ theme_view.name = 'Test Child View modified'
+ test_theme_module.with_context(load_all_views=True)._theme_load(website_1)
+ self.assertEqual(specific_main_view_children.arch, new_arch, "View arch shouldn't have been overrided on theme update as it was modified by user.")
+ self.assertEqual(specific_main_view_children.name, 'Test Child View modified', "View should receive modification on theme update.")
diff --git a/addons/website/tests/test_views_inherit_module_update.py b/addons/website/tests/test_views_inherit_module_update.py
new file mode 100644
index 00000000..04d5d938
--- /dev/null
+++ b/addons/website/tests/test_views_inherit_module_update.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import standalone
+
+"""
+This test ensure `inherit_id` update is correctly replicated on cow views.
+The view receiving the `inherit_id` update is either:
+1. in a module loaded before `website`. In that case, `website` code is not
+ loaded yet, so we store the updates to replay the changes on the cow views
+ once `website` module is loaded (see `_check()`). This test is testing that
+ part.
+2. in a module loaded after `website`. In that case, the `inherit_id` update is
+ directly replicated on the cow views. That behavior is tested with
+ `test_module_new_inherit_view_on_parent_already_forked` and
+ `test_specific_view_module_update_inherit_change` in `website` module.
+"""
+
+
+@standalone('cow_views_inherit')
+def test_01_cow_views_inherit_on_module_update(env):
+ # A B A B
+ # / \ => / \
+ # D D' D D'
+
+ # 1. Setup hierarchy as comment above
+ View = env['ir.ui.view']
+ View.with_context(_force_unlink=True, active_test=False).search([('website_id', '=', 1)]).unlink()
+ child_view = env.ref('portal.footer_language_selector')
+ parent_view = env.ref('portal.portal_back_in_edit_mode')
+ # Change `inherit_id` so the module update will set it back to the XML value
+ child_view.write({'inherit_id': parent_view.id, 'arch': child_view.arch_db.replace('o_footer_copyright_name', 'text-center')})
+ # Trigger COW on view
+ child_view.with_context(website_id=1).write({'name': 'COW Website 1'})
+ child_cow_view = child_view._get_specific_views()
+
+ # 2. Ensure setup is as expected
+ assert child_cow_view.inherit_id == parent_view, "Ensure test is setup as expected."
+
+ # 3. Upgrade the module
+ portal_module = env['ir.module.module'].search([('name', '=', 'portal')])
+ portal_module.button_immediate_upgrade()
+ env.reset() # clear the set of environments
+ env = env() # get an environment that refers to the new registry
+
+ # 4. Ensure cow view also got its inherit_id updated
+ expected_parent_view = env.ref('portal.frontend_layout') # XML data
+ assert child_view.inherit_id == expected_parent_view, "Generic view security check."
+ assert child_cow_view.inherit_id == expected_parent_view, "COW view should also have received the `inherit_id` update."
+
+
+@standalone('cow_views_inherit')
+def test_02_cow_views_inherit_on_module_update(env):
+ # A B B' A B B'
+ # / \ => | |
+ # D D' D D'
+
+ # 1. Setup hierarchy as comment above
+ View = env['ir.ui.view']
+ View.with_context(_force_unlink=True, active_test=False).search([('website_id', '=', 1)]).unlink()
+ view_D = env.ref('portal.my_account_link')
+ view_A = env.ref('portal.message_thread')
+ # Change `inherit_id` so the module update will set it back to the XML value
+ view_D.write({'inherit_id': view_A.id, 'arch_db': view_D.arch_db.replace('o_logout_divider', 'discussion')})
+ # Trigger COW on view
+ view_B = env.ref('portal.user_dropdown') # XML data
+ view_D.with_context(website_id=1).write({'name': 'D Website 1'})
+ view_B.with_context(website_id=1).write({'name': 'B Website 1'})
+ view_Dcow = view_D._get_specific_views()
+
+ # 2. Ensure setup is as expected
+ view_Bcow = view_B._get_specific_views()
+ assert view_Dcow.inherit_id == view_A, "Ensure test is setup as expected."
+ assert len(view_Bcow) == len(view_Dcow) == 1, "Ensure test is setup as expected (2)."
+ assert view_B != view_Bcow, "Security check to ensure `_get_specific_views` return what it should."
+
+ # 3. Upgrade the module
+ portal_module = env['ir.module.module'].search([('name', '=', 'portal')])
+ portal_module.button_immediate_upgrade()
+ env.reset() # clear the set of environments
+ env = env() # get an environment that refers to the new registry
+
+ # 4. Ensure cow view also got its inherit_id updated
+ assert view_D.inherit_id == view_B, "Generic view security check."
+ assert view_Dcow.inherit_id == view_Bcow, "COW view should also have received the `inherit_id` update."
diff --git a/addons/website/tests/test_website_favicon.py b/addons/website/tests/test_website_favicon.py
new file mode 100644
index 00000000..3346ccd4
--- /dev/null
+++ b/addons/website/tests/test_website_favicon.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from PIL import Image
+
+from odoo.tests import tagged
+from odoo.tests.common import TransactionCase
+from odoo.tools import base64_to_image, image_to_base64
+
+
+@tagged('post_install', '-at_install')
+class TestWebsiteResetPassword(TransactionCase):
+
+ def test_01_website_favicon(self):
+ """The goal of this test is to make sure the favicon is correctly
+ handled on the website."""
+
+ # Test setting an Ico file directly, done through create
+ Website = self.env['website']
+
+ website = Website.create({
+ 'name': 'Test Website',
+ 'favicon': Website._default_favicon(),
+ })
+
+ image = base64_to_image(website.favicon)
+ self.assertEqual(image.format, 'ICO')
+
+ # Test setting a JPEG file that is too big, done through write
+ bg_color = (135, 90, 123)
+ image = Image.new('RGB', (1920, 1080), color=bg_color)
+ website.favicon = image_to_base64(image, 'JPEG')
+ image = base64_to_image(website.favicon)
+ self.assertEqual(image.format, 'ICO')
+ self.assertEqual(image.size, (256, 256))
+ self.assertEqual(image.getpixel((0, 0)), bg_color)
diff --git a/addons/website/tests/test_website_reset_password.py b/addons/website/tests/test_website_reset_password.py
new file mode 100644
index 00000000..83e23a56
--- /dev/null
+++ b/addons/website/tests/test_website_reset_password.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from unittest.mock import patch
+
+import odoo
+from odoo.tests import tagged
+from odoo.tests.common import HttpCase
+
+
+@tagged('post_install', '-at_install')
+class TestWebsiteResetPassword(HttpCase):
+
+ def test_01_website_reset_password_tour(self):
+ """The goal of this test is to make sure the reset password works."""
+
+ # We override unlink because we don't want the email to be auto deleted
+ # if the send works.
+ MailMail = odoo.addons.mail.models.mail_mail.MailMail
+
+ # We override send_mail because in HttpCase on runbot we don't have an
+ # SMTP server, so if force_send is set, the test is going to fail.
+ MailTemplate = odoo.addons.mail.models.mail_template.MailTemplate
+ original_send_mail = MailTemplate.send_mail
+
+ def my_send_mail(*args, **kwargs):
+ kwargs.update(force_send=False)
+ return original_send_mail(*args, **kwargs)
+
+ with patch.object(MailMail, 'unlink', lambda self: None), patch.object(MailTemplate, 'send_mail', my_send_mail):
+ user = self.env['res.users'].create({
+ 'login': 'test',
+ 'name': 'The King',
+ 'email': 'noop@example.com',
+ })
+ website_1 = self.env['website'].browse(1)
+ website_2 = self.env['website'].browse(2)
+
+ website_1.domain = "my-test-domain.com"
+ website_2.domain = "https://domain-not-used.fr"
+
+ user.partner_id.website_id = 2
+ user.invalidate_cache() # invalidate get_base_url
+
+ user.action_reset_password()
+ self.assertIn(website_2.domain, user.signup_url)
+
+ user.invalidate_cache()
+
+ user.partner_id.website_id = 1
+ user.action_reset_password()
+ self.assertIn(website_1.domain, user.signup_url)
+
+ (website_1 + website_2).domain = ""
+
+ user.action_reset_password()
+ user.invalidate_cache()
+
+ self.start_tour(user.signup_url, 'website_reset_password', login=None)
+
+ def test_02_multi_user_login(self):
+ # In case Specific User Account is activated on a website, the same login can be used for
+ # several users. Make sure we can still log in if 2 users exist.
+ website = self.env["website"].get_current_website()
+ website.ensure_one()
+
+ # Use AAA and ZZZ as names since res.users are ordered by 'login, name'
+ user1 = self.env["res.users"].create(
+ {"website_id": False, "login": "bobo@mail.com", "name": "AAA", "password": "bobo@mail.com"}
+ )
+ user2 = self.env["res.users"].create(
+ {"website_id": website.id, "login": "bobo@mail.com", "name": "ZZZ", "password": "bobo@mail.com"}
+ )
+
+ # The most specific user should be selected
+ self.authenticate("bobo@mail.com", "bobo@mail.com")
+ self.assertEqual(self.session["uid"], user2.id)
diff --git a/addons/website/tests/test_website_visitor.py b/addons/website/tests/test_website_visitor.py
new file mode 100644
index 00000000..374594f9
--- /dev/null
+++ b/addons/website/tests/test_website_visitor.py
@@ -0,0 +1,326 @@
+# coding: utf-8
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from contextlib import contextmanager
+from datetime import datetime, timedelta
+from unittest.mock import patch
+
+from odoo.addons.base.tests.common import HttpCaseWithUserDemo
+from odoo.addons.website.tools import MockRequest
+from odoo.addons.website.models.website_visitor import WebsiteVisitor
+from odoo.tests import common, tagged
+
+
+class MockVisitor(common.BaseCase):
+
+ @contextmanager
+ def mock_visitor_from_request(self, force_visitor=False):
+
+ def _get_visitor_from_request(model, *args, **kwargs):
+ return force_visitor
+
+ with patch.object(WebsiteVisitor, '_get_visitor_from_request',
+ autospec=True, wraps=WebsiteVisitor,
+ side_effect=_get_visitor_from_request) as _get_visitor_from_request_mock:
+ yield
+
+
+@tagged('-at_install', 'post_install', 'website_visitor')
+class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
+
+ def setUp(self):
+ super(WebsiteVisitorTests, self).setUp()
+
+ self.website = self.env['website'].search([
+ ('company_id', '=', self.env.user.company_id.id)
+ ], limit=1)
+ self.cookies = {}
+
+ untracked_view = self.env['ir.ui.view'].create({
+ 'name': 'UntackedView',
+ 'type': 'qweb',
+ 'arch': '''<t name="Homepage" t-name="website.base_view">
+ <t t-call="website.layout">
+ I am a generic page²
+ </t>
+ </t>''',
+ 'key': 'test.base_view',
+ 'track': False,
+ })
+ tracked_view = self.env['ir.ui.view'].create({
+ 'name': 'TrackedView',
+ 'type': 'qweb',
+ 'arch': '''<t name="Homepage" t-name="website.base_view">
+ <t t-call="website.layout">
+ I am a generic page
+ </t>
+ </t>''',
+ 'key': 'test.base_view',
+ 'track': True,
+ })
+ tracked_view_2 = self.env['ir.ui.view'].create({
+ 'name': 'TrackedView2',
+ 'type': 'qweb',
+ 'arch': '''<t name="OtherPage" t-name="website.base_view">
+ <t t-call="website.layout">
+ I am a generic second page
+ </t>
+ </t>''',
+ 'key': 'test.base_view',
+ 'track': True,
+ })
+ [self.untracked_page, self.tracked_page, self.tracked_page_2] = self.env['website.page'].create([
+ {
+ 'view_id': untracked_view.id,
+ 'url': '/untracked_view',
+ 'website_published': True,
+ },
+ {
+ 'view_id': tracked_view.id,
+ 'url': '/tracked_view',
+ 'website_published': True,
+ },
+ {
+ 'view_id': tracked_view_2.id,
+ 'url': '/tracked_view_2',
+ 'website_published': True,
+ },
+ ])
+
+ self.user_portal = self.env['res.users'].search([('login', '=', 'portal')])
+ self.partner_portal = self.user_portal.partner_id
+ if not self.user_portal:
+ self.env['ir.config_parameter'].sudo().set_param('auth_password_policy.minlength', 4)
+ self.partner_portal = self.env['res.partner'].create({
+ 'name': 'Joel Willis',
+ 'email': 'joel.willis63@example.com',
+ })
+ self.user_portal = self.env['res.users'].create({
+ 'login': 'portal',
+ 'password': 'portal',
+ 'partner_id': self.partner_portal.id,
+ 'groups_id': [(6, 0, [self.env.ref('base.group_portal').id])],
+ })
+
+ def _get_last_visitor(self):
+ return self.env['website.visitor'].search([], limit=1, order="id DESC")
+
+ def assertPageTracked(self, visitor, page):
+ """ Check a page is in visitor tracking data """
+ self.assertIn(page, visitor.website_track_ids.page_id)
+ self.assertIn(page, visitor.page_ids)
+
+ def assertVisitorTracking(self, visitor, pages):
+ """ Check the whole tracking history of a visitor """
+ for page in pages:
+ self.assertPageTracked(visitor, page)
+ self.assertEqual(
+ len(visitor.website_track_ids),
+ len(pages)
+ )
+
+ def assertVisitorDeactivated(self, visitor, main_visitor):
+ """ Temporary method to check that a visitor has been de-activated / merged
+ with other visitor, notably in case of login (see User.authenticate() as
+ well as Visitor._link_to_visitor() ).
+
+ As final result depends on installed modules (see overrides) due to stable
+ improvements linked to EventOnline, this method contains a hack to avoid
+ doing too much overrides just for that behavior. """
+ if 'parent_id' in self.env['website.visitor']:
+ self.assertTrue(bool(visitor))
+ self.assertFalse(visitor.active)
+ self.assertTrue(main_visitor.active)
+ self.assertEqual(visitor.parent_id, main_visitor)
+ else:
+ self.assertFalse(visitor)
+ self.assertTrue(bool(main_visitor))
+
+ def test_visitor_creation_on_tracked_page(self):
+ """ Test various flows involving visitor creation and update. """
+ existing_visitors = self.env['website.visitor'].search([])
+ existing_tracks = self.env['website.track'].search([])
+ self.url_open(self.untracked_page.url)
+ self.url_open(self.tracked_page.url)
+ self.url_open(self.tracked_page.url)
+
+ new_visitor = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
+ new_track = self.env['website.track'].search([('id', 'not in', existing_tracks.ids)])
+ self.assertEqual(len(new_visitor), 1, "1 visitor should be created")
+ self.assertEqual(len(new_track), 1, "There should be 1 tracked page")
+ self.assertEqual(new_visitor.visit_count, 1)
+ self.assertEqual(new_visitor.website_track_ids, new_track)
+ self.assertVisitorTracking(new_visitor, self.tracked_page)
+
+ # ------------------------------------------------------------
+ # Admin connects
+ # ------------------------------------------------------------
+
+ self.cookies = {'visitor_uuid': new_visitor.access_token}
+ with MockRequest(self.env, website=self.website, cookies=self.cookies):
+ self.authenticate(self.user_admin.login, 'admin')
+
+ visitor_admin = new_visitor
+ # visit a page
+ self.url_open(self.tracked_page_2.url)
+
+ # check tracking and visitor / user sync
+ self.assertVisitorTracking(visitor_admin, self.tracked_page | self.tracked_page_2)
+ self.assertEqual(visitor_admin.partner_id, self.partner_admin)
+ self.assertEqual(visitor_admin.name, self.partner_admin.name)
+
+ # ------------------------------------------------------------
+ # Portal connects
+ # ------------------------------------------------------------
+
+ with MockRequest(self.env, website=self.website, cookies=self.cookies):
+ self.authenticate(self.user_portal.login, 'portal')
+
+ self.assertFalse(
+ self.env['website.visitor'].search([('id', 'not in', (existing_visitors | visitor_admin).ids)]),
+ "No extra visitor should be created")
+
+ # visit a page
+ self.url_open(self.tracked_page.url)
+ self.url_open(self.untracked_page.url)
+ self.url_open(self.tracked_page_2.url)
+ self.url_open(self.tracked_page_2.url) # 2 time to be sure it does not record twice
+
+ # new visitor is created
+ new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
+ self.assertEqual(len(new_visitors), 2, "One extra visitor should be created")
+ visitor_portal = new_visitors[0]
+ self.assertEqual(visitor_portal.partner_id, self.partner_portal)
+ self.assertEqual(visitor_portal.name, self.partner_portal.name)
+ self.assertVisitorTracking(visitor_portal, self.tracked_page | self.tracked_page_2)
+
+ # ------------------------------------------------------------
+ # Back to anonymous
+ # ------------------------------------------------------------
+
+ # portal user disconnects
+ self.logout()
+
+ # visit some pages
+ self.url_open(self.tracked_page.url)
+ self.url_open(self.untracked_page.url)
+ self.url_open(self.tracked_page_2.url)
+ self.url_open(self.tracked_page_2.url) # 2 time to be sure it does not record twice
+
+ # new visitor is created
+ new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
+ self.assertEqual(len(new_visitors), 3, "One extra visitor should be created")
+ visitor_anonymous = new_visitors[0]
+ self.cookies['visitor_uuid'] = visitor_anonymous.access_token
+ self.assertFalse(visitor_anonymous.name)
+ self.assertFalse(visitor_anonymous.partner_id)
+ self.assertVisitorTracking(visitor_anonymous, self.tracked_page | self.tracked_page_2)
+ visitor_anonymous_tracks = visitor_anonymous.website_track_ids
+
+ # ------------------------------------------------------------
+ # Admin connects again
+ # ------------------------------------------------------------
+
+ with MockRequest(self.env, website=self.website, cookies=self.cookies):
+ self.authenticate(self.user_admin.login, 'admin')
+
+ # one visitor is deleted
+ visitor_anonymous = self.env['website.visitor'].with_context(active_test=False).search([('id', '=', visitor_anonymous.id)])
+ self.assertVisitorDeactivated(visitor_anonymous, visitor_admin)
+ new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
+ self.assertEqual(new_visitors, visitor_admin | visitor_portal)
+ visitor_admin = self.env['website.visitor'].search([('partner_id', '=', self.partner_admin.id)])
+ # tracks are linked
+ self.assertTrue(visitor_anonymous_tracks < visitor_admin.website_track_ids)
+ self.assertEqual(len(visitor_admin.website_track_ids), 4, "There should be 4 tracked page for the admin")
+
+ # ------------------------------------------------------------
+ # Back to anonymous
+ # ------------------------------------------------------------
+
+ # admin disconnects
+ self.logout()
+
+ # visit some pages
+ self.url_open(self.tracked_page.url)
+ self.url_open(self.untracked_page.url)
+ self.url_open(self.tracked_page_2.url)
+ self.url_open(self.tracked_page_2.url) # 2 time to be sure it does not record twice
+
+ # new visitor created
+ new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
+ self.assertEqual(len(new_visitors), 3, "One extra visitor should be created")
+ visitor_anonymous_2 = new_visitors[0]
+ self.cookies['visitor_uuid'] = visitor_anonymous_2.access_token
+ self.assertFalse(visitor_anonymous_2.name)
+ self.assertFalse(visitor_anonymous_2.partner_id)
+ self.assertVisitorTracking(visitor_anonymous_2, self.tracked_page | self.tracked_page_2)
+ visitor_anonymous_2_tracks = visitor_anonymous_2.website_track_ids
+
+ # ------------------------------------------------------------
+ # Portal connects again
+ # ------------------------------------------------------------
+ with MockRequest(self.env, website=self.website, cookies=self.cookies):
+ self.authenticate(self.user_portal.login, 'portal')
+
+ # one visitor is deleted
+ new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
+ self.assertEqual(new_visitors, visitor_admin | visitor_portal)
+ # tracks are linked
+ self.assertTrue(visitor_anonymous_2_tracks < visitor_portal.website_track_ids)
+ self.assertEqual(len(visitor_portal.website_track_ids), 4, "There should be 4 tracked page for the portal user")
+
+ # simulate the portal user comes back 30min later
+ for track in visitor_portal.website_track_ids:
+ track.write({'visit_datetime': track.visit_datetime - timedelta(minutes=30)})
+
+ # visit a page
+ self.url_open(self.tracked_page.url)
+ visitor_portal.invalidate_cache(fnames=['website_track_ids'])
+ # tracks are created
+ self.assertEqual(len(visitor_portal.website_track_ids), 5, "There should be 5 tracked page for the portal user")
+
+ # simulate the portal user comes back 8hours later
+ visitor_portal.write({'last_connection_datetime': visitor_portal.last_connection_datetime - timedelta(hours=8)})
+ self.url_open(self.tracked_page.url)
+ visitor_portal.invalidate_cache(fnames=['visit_count'])
+ # check number of visits
+ self.assertEqual(visitor_portal.visit_count, 2, "There should be 2 visits for the portal user")
+
+ def test_visitor_archive(self):
+ """ Test cron archiving inactive visitors and their re-activation when
+ authenticating an user. """
+ self.env['ir.config_parameter'].sudo().set_param('website.visitor.live.days', 7)
+
+ partner_demo = self.partner_demo
+ old_visitor = self.env['website.visitor'].create({
+ 'lang_id': self.env.ref('base.lang_en').id,
+ 'country_id': self.env.ref('base.be').id,
+ 'website_id': 1,
+ 'partner_id': partner_demo.id,
+ })
+ self.assertTrue(old_visitor.active)
+ self.assertEqual(partner_demo.visitor_ids, old_visitor, "Visitor and its partner should be synchronized")
+
+ # archive old visitor
+ old_visitor.last_connection_datetime = datetime.now() - timedelta(days=8)
+ self.env['website.visitor']._cron_archive_visitors()
+ self.assertEqual(old_visitor.active, False, "Visitor should be archived after inactivity")
+
+ # reconnect with new visitor.
+ self.url_open(self.tracked_page.url)
+ new_visitor = self._get_last_visitor()
+ self.assertFalse(new_visitor.partner_id)
+ self.assertTrue(new_visitor.id > old_visitor.id, "A new visitor should have been created.")
+ self.assertVisitorTracking(new_visitor, self.tracked_page)
+
+ with self.mock_visitor_from_request(force_visitor=new_visitor):
+ self.authenticate('demo', 'demo')
+ (new_visitor | old_visitor).flush()
+ partner_demo.flush()
+ partner_demo.invalidate_cache(fnames=['visitor_ids'])
+ self.assertEqual(partner_demo.visitor_ids, old_visitor, "The partner visitor should be back to the 'old' visitor.")
+
+ new_visitor = self.env['website.visitor'].search([('id', '=', new_visitor.id)])
+ self.assertEqual(len(new_visitor), 0, "The new visitor should be deleted when visitor authenticate once again.")
+ self.assertEqual(old_visitor.active, True, "The old visitor should be reactivated when visitor authenticates once again.")