diff --git a/README.md b/README.md index d9bfd3b..4b0aa0b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,112 @@ -# website +Odoo Website Builder +-------------------- +Get an awesome and free website, +easily customizable with the Odoo website builder. + +Create enterprise grade website with our super easy builder. Use finely +designed building blocks and edit everything inline. + +Benefit from out-of-the-box business features; e-Commerce, events, blogs, jobs +announces, customer references, call-to-actions, etc. + +Edit Anything Inline +-------------------- + +Create beautiful websites with no technical knowledge. Odoo's unique *'edit +inline'* approach makes website creation surprisingly easy. No more complex +backend; just click anywhere to change any content. + +"Want to change the price of a product? or put it in bold? Want to change a +blog title?" Just click and change. What you see is what you get. Really. + +Awesome. Astonishingly Beautiful. +--------------------------------- + +Odoo's building blocks allow to design modern websites that are not possible +with traditional WYSIWYG page editors. + +Whether it's for products descriptions, blogs or static pages, you don't need +to be a professional designer to create clean contents. Just drag and drop and +customize predefined building blocks. + +Enterprise-Ready, out-of-the-box +-------------------------------- + +Activate ready-to-use enterprise features in just a click; e-commerce, +call-to-actions, jobs announces, events, customer references, blogs, etc. + +Traditional eCommerce and CMS have poorly designed backends as it's not their +core focus. With the Odoo integration, you benefit from the best management +software to follow-up on your orders, your jobs applicants, your leads, etc. + +A Great Mobile Experience +------------------------- + +Get a mobile friendly website thanks to our responsive design based on +bootstrap. All your pages adapt automatically to the screen size. (mobile +phones, tablets, desktop) You don't have to worry about mobile contents, it +works by default. + +SEO tools at your finger tips +----------------------------- + +The *Promote* tool suggests keywords according to Google most searched terms. +Search Engine Optimization tools are ready to use, with no configuration +required. + +Google Analytics tracks your shopping cart events by default. Sitemap and +structured content are created automatically for Google indexation. + +Multi-Languages Made Easy +------------------------- + +Get your website translated in multiple languages with no effort. Odoo proposes +and propagates translations automatically across pages, following what you edit +on the master page. + +Designer-Friendly Templates +--------------------------- + +Templates are awesome and easy to design. You don't need to develop to create +new pages, themes or building blocks. We use a clean HTML structure, a +[bootstrap](http://getbootstrap.com/) CSS. + +Customize every page on the fly with the integrated template editor. Distribute +your work easily as an Odoo module. + +Fluid Grid Layouting +-------------------- + +Design perfect pages by drag and dropping building blocks. Move and scale them +to fit the layout you are looking for. + +Building blocks are based on a responsive, mobile friendly fluid grid system +that appropriately scales up to 12 columns as the device or viewport size +increases. + +Professional Themes +------------------- + +Design a custom theme or reuse pre-defined themes to customize the look and +feel of your website. + +Test new color scheme easily; you can change your theme at any time in just a +click. + +Integrated With Odoo Apps +------------------------- + +### e-Commerce + +Promote products, sell online, optimize visitors' shopping experience. + + +### Blog + +Write news, attract new visitors, build customer loyalty. + + +### Online Events + +Schedule, organize, promote or sell events online; conferences, trainings, webinars, etc. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..7d96f2f --- /dev/null +++ b/__init__.py @@ -0,0 +1,46 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models +from . import wizard + +import odoo +from odoo import api, SUPERUSER_ID +from odoo.http import request +from functools import partial + + +def uninstall_hook(env): + # Force remove ondelete='cascade' elements, + # This might be prevented by another ondelete='restrict' field + # TODO: This should be an Odoo generic fix, not a website specific one + website_domain = [('website_id', '!=', False)] + env['ir.asset'].search(website_domain).unlink() + env['ir.ui.view'].search(website_domain).with_context(active_test=False, _force_unlink=True).unlink() + + # Cleanup records which are related to websites and will not be autocleaned + # by the uninstall operation. This must be done here in the uninstall_hook + # as during an uninstallation, `unlink` is not called for records which were + # created by the user (not XML data). Same goes for @api.ondelete available + # from 15.0 and above. + env['website'].search([])._remove_attachments_on_website_unlink() + + # Properly unlink website_id from ir.model.fields + def rem_website_id_null(dbname): + db_registry = odoo.modules.registry.Registry.new(dbname) + with db_registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + env['ir.model.fields'].search([ + ('name', '=', 'website_id'), + ('model', '=', 'res.config.settings'), + ]).unlink() + + env.cr.postcommit.add(partial(rem_website_id_null, env.cr.dbname)) + + +def post_init_hook(env): + env['ir.module.module'].update_theme_images() + + if request: + env = env(context=request.default_context()) + request.website_routing = env['website'].get_current_website().id diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..c138b65 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,384 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Website', + 'category': 'Website/Website', + 'sequence': 20, + 'summary': 'Enterprise website builder', + 'website': 'https://www.odoo.com/app/website', + 'version': '1.0', + 'depends': [ + 'digest', + 'web', + 'web_editor', + 'http_routing', + 'portal', + 'social_media', + 'auth_signup', + 'mail', + 'google_recaptcha', + 'utm', + ], + 'installable': True, + 'data': [ + # security.xml first, data.xml need the group to exist (checking it) + 'security/website_security.xml', + 'security/ir.model.access.csv', + 'data/ir_asset.xml', + 'data/ir_cron_data.xml', + 'data/mail_mail_data.xml', + 'data/website_data.xml', + 'data/website_visitor_cron.xml', + 'data/digest_data.xml', + 'views/website_templates.xml', + 'views/snippets/snippets.xml', + 'views/snippets/s_title.xml', + 'views/snippets/s_cover.xml', + 'views/snippets/s_text_cover.xml', + 'views/snippets/s_text_image.xml', + 'views/snippets/s_image_text.xml', + 'views/snippets/s_instagram_page.xml', + 'views/snippets/s_banner.xml', + 'views/snippets/s_text_block.xml', + 'views/snippets/s_features.xml', + 'views/snippets/s_three_columns.xml', + 'views/snippets/s_picture.xml', + 'views/snippets/s_carousel.xml', + 'views/snippets/s_alert.xml', + 'views/snippets/s_card.xml', + 'views/snippets/s_share.xml', + 'views/snippets/s_social_media.xml', + 'views/snippets/s_rating.xml', + 'views/snippets/s_hr.xml', + 'views/snippets/s_facebook_page.xml', + 'views/snippets/s_image_gallery.xml', + 'views/snippets/s_countdown.xml', + 'views/snippets/s_product_catalog.xml', + 'views/snippets/s_comparisons.xml', + 'views/snippets/s_company_team.xml', + 'views/snippets/s_call_to_action.xml', + 'views/snippets/s_references.xml', + 'views/snippets/s_popup.xml', + 'views/snippets/s_faq_collapse.xml', + 'views/snippets/s_features_grid.xml', + 'views/snippets/s_tabs.xml', + 'views/snippets/s_table_of_content.xml', + 'views/snippets/s_chart.xml', + 'views/snippets/s_parallax.xml', + 'views/snippets/s_quotes_carousel.xml', + 'views/snippets/s_numbers.xml', + 'views/snippets/s_masonry_block.xml', + 'views/snippets/s_media_list.xml', + 'views/snippets/s_showcase.xml', + 'views/snippets/s_timeline.xml', + 'views/snippets/s_process_steps.xml', + 'views/snippets/s_text_highlight.xml', + 'views/snippets/s_progress_bar.xml', + 'views/snippets/s_blockquote.xml', + 'views/snippets/s_badge.xml', + 'views/snippets/s_color_blocks_2.xml', + 'views/snippets/s_product_list.xml', + 'views/snippets/s_mega_menu_multi_menus.xml', + 'views/snippets/s_mega_menu_menu_image_menu.xml', + 'views/snippets/s_mega_menu_thumbnails.xml', + 'views/snippets/s_mega_menu_little_icons.xml', + 'views/snippets/s_mega_menu_images_subtitles.xml', + 'views/snippets/s_mega_menu_menus_logos.xml', + 'views/snippets/s_mega_menu_odoo_menu.xml', + 'views/snippets/s_mega_menu_big_icons_subtitles.xml', + 'views/snippets/s_mega_menu_cards.xml', + 'views/snippets/s_google_map.xml', + 'views/snippets/s_map.xml', + 'views/snippets/s_dynamic_snippet.xml', + 'views/snippets/s_dynamic_snippet_carousel.xml', + 'views/snippets/s_embed_code.xml', + 'views/snippets/s_website_controller_page_listing_layout.xml', + 'views/snippets/s_website_form.xml', + 'views/snippets/s_searchbar.xml', + 'views/snippets/s_button.xml', + 'views/snippets/s_image.xml', + 'views/snippets/s_video.xml', + 'views/new_page_template_templates.xml', + 'views/website_views.xml', + 'views/website_pages_views.xml', + 'views/website_controller_pages_views.xml', + 'views/website_visitor_views.xml', + 'views/res_config_settings_views.xml', + 'views/website_rewrite.xml', + 'views/ir_actions_server_views.xml', + 'views/ir_asset_views.xml', + 'views/ir_attachment_views.xml', + 'views/ir_model_views.xml', + 'views/res_partner_views.xml', + 'views/neutralize_views.xml', + 'wizard/base_language_install_views.xml', + 'wizard/website_robots.xml', + ], + 'demo': [ + 'data/website_demo.xml', + 'data/website_visitor_demo.xml', + ], + 'application': True, + 'post_init_hook': 'post_init_hook', + 'uninstall_hook': 'uninstall_hook', + 'assets': { + 'web.assets_frontend': [ + ('replace', 'web/static/src/legacy/js/public/public_root_instance.js', 'website/static/src/js/content/website_root_instance.js'), + 'website/static/src/libs/zoomodoo/zoomodoo.scss', + 'website/static/src/scss/website.scss', + 'website/static/src/scss/website_controller_page.scss', + 'website/static/src/scss/website.ui.scss', + 'website/static/src/libs/zoomodoo/zoomodoo.js', + 'website/static/src/js/utils.js', + 'web/static/src/core/autocomplete/*', + 'website/static/src/components/autocomplete_with_pages/*', + 'website/static/src/js/tours/tour_utils.js', + 'website/static/src/js/content/website_root.js', + 'website/static/src/js/widgets/dialog.js', + 'website/static/src/js/content/compatibility.js', + 'website/static/src/js/content/menu.js', + 'website/static/src/js/content/snippets.animation.js', + 'website/static/src/js/show_password.js', + 'website/static/src/js/post_link.js', + 'website/static/src/js/plausible.js', + 'website/static/src/js/website_controller_page_listing_layout.js', + 'website/static/src/js/user_custom_javascript.js', + 'website/static/src/js/http_cookie.js', + 'website/static/src/xml/website.xml', + 'website/static/src/xml/website.background.video.xml', + 'website/static/src/xml/website.share.xml', + 'website/static/src/js/text_processing.js', + # Stable fix, will be replaced by an `ir.asset` in master to be able + # to clean ` + + + ''' + # Access page. + self.start_tour('/', 'test_unsplash_beacon') diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 0000000..bb7f08d --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,1563 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from hashlib import sha256 +import copy +import unittest +from unittest.mock import patch +from itertools import zip_longest +from lxml import etree as ET, html +from lxml.html import builder as h + +from odoo.modules.module import _DEFAULT_MANIFEST +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'

hello world

', + 'type': 'qweb' + }) + # script and style text nodes should not escaped client side + replacement = u'' + view.save(replacement, xpath='/t/p/h1') + self.assertIn( + replacement.replace(u'&', u'&'), + view.arch, + 'inline script should be escaped server side' + ) + self.assertIn( + replacement, + self.env['ir.qweb']._render(view.id), + 'inline script should not be escaped when rendering' + ) + # common text nodes should be be escaped client side + replacement = u'world & <b>cie' + view.save(replacement, xpath='/t/p') + self.assertIn(replacement, view.arch, 'common text node should not be escaped server side') + self.assertIn( + replacement, + str(self.env['ir.qweb']._render(view.id)).replace(u'&', u'&'), + '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'
', + 'type': 'qweb' + }).with_context(website_id=1, load_all_views=True) + replacement = u'
hello
' + view.save(replacement, xpath='/t/div') + # branding data-oe-* should be stripped + self.assertIn( + '
hello
', + view.get_combined_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': '
base content
', + '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': '
, extended content
', + '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': '
modified content
'}) + self.assertEqual(View.search_count([('key', '=', 'website.base_view')]), 1) + self.assertEqual(View.search_count([('key', '=', 'website.extension_view')]), 1) + + arch = self.base_view.get_combined_arch() + self.assertEqual(arch, '
modified content
') + + # edit on frontend, copy just the leaf + self.inherit_view.with_context(website_id=1).write({'arch': '
website 1 content
'}) + 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.get_combined_arch() + self.assertEqual(arch, '
modified content
') + # read on website should reflect change + arch = self.base_view.with_context(website_id=1).get_combined_arch() + self.assertEqual(arch, '
website 1 content
') + + # 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).get_combined_arch() + self.assertEqual(arch, '
base content
') + + def test_cow_root(self): + View = self.env['ir.ui.view'] + + # edit on backend, regular write + self.base_view.write({'arch': '
modified base content
'}) + 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': '
website 1 content
'}) + + 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).get_combined_arch() + self.assertEqual(arch, '
modified base content, extended content
') + + arch = website_specific_base_view.with_context(load_all_views=True, website_id=1).get_combined_arch() + self.assertEqual(arch, '
website 1 content, extended content
') + + # # 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': '
modified base content
'}) + # 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': '
website 1 content
'}) + # 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': '
content
', + }) + + 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 = ''' + +
+
+
+

Second View

+
+
+
+ + ''' + 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('
First editable_part
' % second_view.id, "/t[1]/t[1]/div[1]/div[1]") + second_view.with_context(website_id=1).save('
Second editable_part
' % 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': '
Hi
'}) + self.inherit_view.write({'arch': '
World
'}) + + # 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).get_combined_arch() + self.assertIn('Hi World', arch) + + self.base_view.write({'arch': '
Hello
'}) + + # 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).get_combined_arch() + self.assertIn('Hello World', arch) + + self.base_view.with_context(website_id=1).write({'arch': '
Bye
'}) + + # 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': '
All
'}) + + # 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).get_combined_arch() + self.assertEqual('Bye All' in arch, True) + + self.inherit_view.with_context(website_id=1).write({'arch': '
Nobody
'}) + + # 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).get_combined_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.get_combined_arch() + base_arch_w1 = self.base_view.with_context(website_id=1).get_combined_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 get_combined_arch 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': 'GENERIC
A
', + 'key': 'website.main_view', + }).with_context(load_all_views=True) + + View.create({ + 'name': 'Child View', + 'mode': 'extension', + 'inherit_id': main_view.id, + 'arch': '
VIEW

B

', + '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': 'C', + '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': 'D'}) + 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': 'SPECIFIC
Z
'}) + 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).get_combined_arch() + specific_view_arch = View.browse(specific_view).with_context(load_all_views=True, website_id=1).get_combined_arch() + self.assertEqual(generic_view_arch, 'GENERIC
VIEWC
') + self.assertEqual(specific_view_arch, 'SPECIFIC
VIEWD
', "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': '
, sub ext
', + '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': '
, sub sibling specific
', + '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': ''' +
+ +
+ ''', + }) + + products_item = View.create({ + 'name': 'Products item', + 'type': 'qweb', + 'key': '_website_sale.products_item', + 'arch': ''' +
+ ''', + }) + + 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': ''' + + ''', + }) + + 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': ''' +
+ +
+ ''', + }) + + 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, PRIORITY, ID). + """ + self.inherit_view.copy({ + 'name': 'alphabetically before "Extension"', + 'key': '_test.alphabetically_first', + 'arch': '

COMPARE

', + }) + # 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_write_order_vs_cow_inherit_children_order(self): + """ When both a specific inheriting view and a non-specific base view + are written simultaneously, the specific inheriting base view + must be updated even though its id will change during the COW of + the base view. + """ + View = self.env['ir.ui.view'] + self.inherit_view.with_context(website_id=1).write({'name': 'Specific Inherited View Changed First'}) + specific_view = View.search([('name', '=', 'Specific Inherited View Changed First')]) + views = View.browse([self.base_view.id, specific_view.id]) + views.with_context(website_id=1).write({'active': False}) + new_specific_view = View.search([('name', '=', 'Specific Inherited View Changed First')]) + self.assertTrue(specific_view.id != new_specific_view.id, "Should have a new id") + self.assertFalse(new_specific_view.active, "Should have been deactivated") + + def test_write_order_vs_cow_inherit_children_order_alt(self): + """ Same as the previous test, but requesting the update in the + opposite order. + """ + View = self.env['ir.ui.view'] + self.inherit_view.with_context(website_id=1).write({'name': 'Specific Inherited View Changed First'}) + specific_view = View.search([('name', '=', 'Specific Inherited View Changed First')]) + views = View.browse([specific_view.id, self.base_view.id]) + views.with_context(website_id=1).write({'active': False}) + new_specific_view = View.search([('name', '=', 'Specific Inherited View Changed First')]) + self.assertTrue(specific_view.id != new_specific_view.id, "Should have a new id") + self.assertFalse(new_specific_view.active, "Should have been deactivated") + + 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': '

COMPARE

', + 'key': '_website_sale_comparison.product_add_to_compare', + })]) + View.invalidate_model() + + # 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.get_combined_arch() + self.assertEqual(specific_view.website_id.id, 1, "Ensure we got specific view to perform the checks against") + self.assertEqual(specific_view_arch, '

COMPARE

', "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': '

COMPARE EDITED

', + })]) + specific_view_arch = Website.with_context(load_all_views=True, website_id=1).viewref('_website_sale.product').get_combined_arch() + self.assertEqual(specific_view_arch, '

COMPARE EDITED

', "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_model() + 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': '', + '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': '', + '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': '', + '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': '
, extended content 2
', + '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': '
modified content
'}) + + # 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': '
base content
', + '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': '
extended content
', + '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': '

EXTENSION

', + 'key': 'website.extension2_view', + })]) + finally: + View.pool._init = original_pool_init + + def test_specific_view_translation(self): + self.env['res.lang']._activate_lang('fr_BE') + self.base_view.with_context(lang='en_US').arch_db = '
hello
' + self.base_view.update_field_translations('arch_db', {'fr_BE': {'hello': 'bonjour'}}) + self.assertEqual(self.base_view.with_context(lang='fr_BE').arch, '
bonjour
') + 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='fr_BE').arch, '
bonjour
', + "copy on write (COW) also copy existing translations") + + self.base_view.update_field_translations('arch_db', {'fr_BE': {'bonjour': 'salut'}}) + self.assertEqual(self.base_view.with_context(lang='fr_BE').arch, '
salut
') + self.assertEqual(specific_view.with_context(lang='fr_BE').arch, '
bonjour
', + "updating translation of base view doesn't update specific view") + + self.env['res.lang']._activate_lang('es_ES') + # Translate specific 'arch_db' while the generic value has no content for the + # translation language. + specific_view.update_field_translations('arch_db', {'es_ES': {'hello': 'hola'}}) + self.assertEqual(specific_view.with_context(lang='es_ES').arch, '
hola
') + + self.env['ir.module.module']._load_module_terms(['website'], ['en_US', 'fr_BE', 'es_ES'], overwrite=True) + + specific_view.invalidate_model(['arch_db', 'arch']) + self.assertEqual(specific_view.with_context(lang='fr_BE').arch, '
salut
', + "loading module translation copy translation from base to specific view") + + self.assertEqual(specific_view.with_context(lang='es_ES').arch, '
hola
', + "loading module translation should not remove specific translations that are not available on base view") + + # Make sure updating a short list of languages does not destroy existing translations. + self.env['res.lang']._activate_lang('nl_NL') + + self.env['ir.module.module']._load_module_terms(['website'], ['nl_NL'], overwrite=True) + + specific_view.invalidate_model(['arch_db', 'arch']) + self.assertEqual(specific_view.with_context(lang='fr_BE').arch, '
salut
', + "loading module translation for a specific language should not remove existing translations for other languages") + + self.assertEqual(specific_view.with_context(lang='es_ES').arch, '
hola
', + "loading module translation for a specific language should not remove existing translations for other languages") + + def test_view_to_translate_tag(self): + fr_BE = self.env['res.lang']._activate_lang('fr_BE') + self.base_view.with_context(lang='en_US').arch_db = '
hello
' + self.assertFalse(self.base_view.website_id) + website = self.env['website'].browse(1) + website.default_lang_id = fr_BE + self.base_view.with_context(website_id=1).write({'active': True}) + specific_view = self.base_view._get_specific_views() - self.base_view + + # generic view without website_id but with website for request + with patch('odoo.addons.website.models.ir_http.get_request_website', lambda: website): + self.base_view.invalidate_recordset() + self.assertIn('to_translate', self.base_view.with_context(lang='en_US', edit_translations=True).arch) + self.assertIn('translated', self.base_view.with_context(lang='fr_BE', edit_translations=True).arch) + self.base_view.invalidate_recordset() + + # generic view without website_id + self.assertIn('translated', self.base_view.with_context(lang='en_US', edit_translations=True).arch) + self.assertIn('to_translate', self.base_view.with_context(lang='fr_BE', edit_translations=True).arch) + self.base_view.update_field_translations('arch_db', {'fr_BE': {'hello': 'bonjour'}}) + self.assertIn('translated', self.base_view.with_context(lang='en_US', edit_translations=True).arch) + self.assertIn('translated', self.base_view.with_context(lang='fr_BE', edit_translations=True).arch) + + # specific view with website_id + self.assertIn('to_translate', specific_view.with_context(lang='en_US', edit_translations=True).arch) + self.assertIn('translated', specific_view.with_context(lang='fr_BE', edit_translations=True).arch) + + def test_soc_complete_flow(self): + """ + Check the creation of views from specific-website environments. + """ + View = self.env['ir.ui.view'] + + View.with_context(website_id=1).create({ + 'name': 'Name', + 'key': 'website.no_website_id', + 'type': 'qweb', + 'arch': '', + }) + # Get created views by searching to consider potential unwanted COW + created_views = View.search([('key', '=', 'website.no_website_id')]) + self.assertEqual(len(created_views), 1, "Should only have created one view") + self.assertEqual(created_views.website_id.id, 1, "The created view should be specific to website 1") + + with self.assertRaises(ValueError, msg="Should not allow to create generic view explicitely from website 1 specific context"): + View.with_context(website_id=1).create({ + 'name': 'Name', + 'key': 'website.explicit_no_website_id', + 'type': 'qweb', + 'arch': '', + 'website_id': False, + }) + + with self.assertRaises(ValueError, msg="Should not allow to create specific view for website 2 from website 1 specific context"): + View.with_context(website_id=1).create({ + 'name': 'Name', + 'key': 'website.different_website_id', + 'type': 'qweb', + 'arch': '', + 'website_id': 2, + }) + + 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_model() + base_view_2 = self.base_view.copy({'key': 'website.base_view2', 'arch': '
base2 content
'}) + self.base_view.with_context(website_id=1).write({'arch': '
website 1 content
'}) + 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': '
, extended content website 1
'}) + 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") + + def test_no_cow_on_translate(self): + french = self.env['res.lang']._activate_lang('fr_FR') + self.env['ir.module.module']._load_module_terms(['website'], [french.code]) + # Make sure res.lang.get_installed is recomputed + self.env.registry.clear_cache() + + View = self.env['ir.ui.view'].with_context(lang=french.code, website_id=1) + old_specific_views = View.search([('website_id', '!=', None)]) + view = self.base_view.with_context(lang=french.code, website_id=1) + + root = html.fromstring(self.base_view.arch, parser=html.HTMLParser(encoding="utf-8")) + to_translate = root.text_content() + sha = sha256(to_translate.encode()).hexdigest() + view.update_field_translations_sha('arch_db', {french.code: {sha: 'contenu de base'}}) + + new_specific_views = View.search([('website_id', '!=', None)]) + self.assertEqual(len(old_specific_views), len(new_specific_views), "No additional specific view must have been created") + self.assertTrue(view.arch.index('contenu de base') > 0, "New translation must appear in view") + + +@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': '
base content
', + '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': '
, extended content
', + 'key': 'website.extension_view', + }) + + def test_get_switchable_related_views(self): + 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': '
Arch is not important in this test
', + }) + 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'}) + + self.env.flush_all() + + # 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 = website_1.get_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': '
, trigger COW, arch is not relevant in this test
'}) + # 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': '
, trigger COW, arch is not relevant in this test
'}) + # 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': 'Arch is not relevant for this test', + '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': 'It is really not relevant!', + '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': 'C', + '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': 'Really really not important for this test', + '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': '
', + 'key': '_theme_kea_sale.t_called_view', + }) + View.create({ + 'name': 'Called View Kea', + 'type': 'qweb', + 'arch': '
', + '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': ''}) + + # ##################################################### 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 = website_1.get_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': 'Arch is not relevant for this test', + 'key': '_test.main_view', + }).with_context(load_all_views=True) + # Trigger COW + main_view.with_context(website_id=website_1.id).arch = 'specific' + + # 2. Simulate a theme install with a child view of `main_view` + patcher = patch('odoo.modules.module._get_manifest_cached', return_value=copy.deepcopy(_DEFAULT_MANIFEST)) + self.startPatcher(patcher) + 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': 'C', + '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 = 'Odoo Change01' + 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 = 'Odoo Change02' + 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 = 'Odoo' + 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/tests/test_views_inherit_module_update.py b/tests/test_views_inherit_module_update.py new file mode 100644 index 0000000..559a03e --- /dev/null +++ b/tests/test_views_inherit_module_update.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +""" 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. +""" + +from odoo.tests import standalone + + +@standalone('cow_views_inherit', 'website_standalone') +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') + # Remove any possibly existing COW view (another theme etc) + parent_view.with_context(_force_unlink=True, active_test=False)._get_specific_views().unlink() + child_view.with_context(_force_unlink=True, active_test=False)._get_specific_views().unlink() + # 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 len(child_cow_view.inherit_id) == 1, "Should only be the XML view and its COW counterpart." + 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', 'website_standalone') +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/tests/test_website_favicon.py b/tests/test_website_favicon.py new file mode 100644 index 0000000..3346ccd --- /dev/null +++ b/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/tests/test_website_form_editor.py b/tests/test_website_form_editor.py new file mode 100644 index 0000000..2df90e5 --- /dev/null +++ b/tests/test_website_form_editor.py @@ -0,0 +1,73 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +# -*- coding: utf-8 -*- + +from odoo.http import request +from odoo.addons.base.tests.common import HttpCaseWithUserPortal +from odoo.addons.website.controllers.form import WebsiteForm +from odoo.addons.website.tools import MockRequest +from odoo.tests.common import tagged, TransactionCase + + +@tagged('post_install', '-at_install') +class TestWebsiteFormEditor(HttpCaseWithUserPortal): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.company.email = "info@yourcompany.example.com" + cls.env.ref("base.user_admin").write({ + 'name': "Mitchell Admin", + 'phone': "+1 555-555-5555", + }) + + def test_tour(self): + self.start_tour(self.env['website'].get_client_action_url('/'), 'website_form_editor_tour', login='admin', timeout=120) + self.start_tour('/', 'website_form_editor_tour_submit') + self.start_tour('/', 'website_form_editor_tour_results', login="admin") + + def test_website_form_contact_us_edition_with_email(self): + self.start_tour('/web', 'website_form_contactus_edition_with_email', login="admin") + self.start_tour('/contactus', 'website_form_contactus_submit', login="portal") + mail = self.env['mail.mail'].search([], order='id desc', limit=1) + self.assertEqual( + mail.email_to, + 'test@test.test', + 'The email was edited, the form should have been sent to the configured email') + + def test_website_form_contact_us_edition_no_email(self): + self.env.company.email = 'website_form_contactus_edition_no_email@mail.com' + self.start_tour('/web', 'website_form_contactus_edition_no_email', login="admin") + self.start_tour('/contactus', 'website_form_contactus_submit', login="portal") + mail = self.env['mail.mail'].search([], order='id desc', limit=1) + self.assertEqual( + mail.email_to, + self.env.company.email, + 'The email was not edited, the form should still have been sent to the company email') + + def test_website_form_conditional_required_checkboxes(self): + self.start_tour('/', 'website_form_conditional_required_checkboxes', login="admin") + + def test_contactus_form_email_stay_dynamic(self): + # The contactus form should always be sent to the company email except + # if the user explicitly changed it in the options. + self.env.company.email = 'before.change@mail.com' + self.start_tour('/contactus', 'website_form_contactus_change_random_option', login="admin") + self.env.company.email = 'after.change@mail.com' + self.start_tour('/contactus', 'website_form_contactus_check_changed_email', login="portal") + + +@tagged('post_install', '-at_install') +class TestWebsiteForm(TransactionCase): + + def test_website_form_html_escaping(self): + website = self.env['website'].browse(1) + WebsiteFormController = WebsiteForm() + with MockRequest(self.env, website=website): + WebsiteFormController.insert_record( + request, + self.env['ir.model'].search([('model', '=', 'mail.mail')]), + {'email_from': 'odoobot@example.com', 'subject': 'John Smith', 'email_to': 'company@company.company'}, + "John Smith", + ) + mail = self.env['mail.mail'].search([], order='id desc', limit=1) + self.assertNotIn('', mail.body_html, "HTML should be escaped in website form") + self.assertIn('<b>', mail.body_html, "HTML should be escaped in website form (2)") diff --git a/tests/test_website_reset_password.py b/tests/test_website_reset_password.py new file mode 100644 index 0000000..8cdd6da --- /dev/null +++ b/tests/test_website_reset_password.py @@ -0,0 +1,107 @@ +# -*- 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', + }) + websites = self.env['website'].search([]) + website_1 = websites[0] + if len(websites) == 1: + website_2 = self.env['website'].create({ + 'name': 'My Website 2', + 'domain': '', + 'sequence': 20, + }) + else: + website_2 = websites[1] + + website_1.domain = "my-test-domain.com" + website_2.domain = "https://domain-not-used.fr" + + user.partner_id.website_id = website_2.id + self.env.invalidate_all() # invalidate get_base_url + + user.action_reset_password() + self.assertIn(website_2.domain, user.signup_url) + + self.env.invalidate_all() + + user.partner_id.website_id = website_1.id + user.action_reset_password() + self.assertIn(website_1.domain, user.signup_url) + + (website_1 + website_2).domain = False + + user.action_reset_password() + self.env.invalidate_all() + + 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' + 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) + + def test_multi_website_reset_password_user_specific_user_account(self): + # Create same user on different websites with 'Specific User Account' + # option enabled and then reset password. Only the user from the + # current website should be reset. + website_1, website_2 = self.env['website'].create([ + {'name': 'Website 1', 'specific_user_account': True}, + {'name': 'Website 2', 'specific_user_account': True}, + ]) + + login = 'user@example.com' # same login for both users + user_website_1, user_website_2 = self.env['res.users'].with_context(no_reset_password=True).create([ + {'website_id': website_1.id, 'login': login, 'email': login, 'name': login}, + {'website_id': website_2.id, 'login': login, 'email': login, 'name': login}, + ]) + + self.assertFalse(user_website_1.signup_valid) + self.assertFalse(user_website_2.signup_valid) + + self.env['res.users'].with_context(website_id=website_1.id).reset_password(login) + + self.assertTrue(user_website_1.signup_valid) + self.assertFalse(user_website_2.signup_valid) diff --git a/tests/test_website_visitor.py b/tests/test_website_visitor.py new file mode 100644 index 0000000..df81a10 --- /dev/null +++ b/tests/test_website_visitor.py @@ -0,0 +1,507 @@ +# coding: utf-8 +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import random + +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.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) + + untracked_view = self.env['ir.ui.view'].create({ + 'name': 'UntackedView', + 'type': 'qweb', + 'arch': ''' + + I am a generic page² + + ''', + 'key': 'test.base_view', + 'track': False, + }) + tracked_view = self.env['ir.ui.view'].create({ + 'name': 'TrackedView', + 'type': 'qweb', + 'arch': ''' + + I am a generic page + + ''', + 'key': 'test.base_view', + 'track': True, + }) + tracked_view_2 = self.env['ir.ui.view'].create({ + 'name': 'TrackedView2', + 'type': 'qweb', + 'arch': ''' + + I am a generic second page + + ''', + '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])], + }) + # Partner with no user associated, to test partner merge that forbids merging partners with more than 1 user + self.partner_admin_duplicate = self.env['res.partner'].create({'name': 'Mitchell'}) + + 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): + """ Method that checks that a visitor has been de-activated / merged + with other visitor, notably in case of login (see User.authenticate() as + well as Visitor._merge_visitor() ). """ + self.assertFalse(visitor.exists(), "The anonymous visitor should be deleted") + self.assertTrue(visitor.website_track_ids < main_visitor.website_track_ids) + + def test_visitor_creation_on_tracked_page(self): + """ Test various flows involving visitor creation and update. """ + + def authenticate(login, pwd): + # We can't call `self.authenticate` because that tour util is + # regenerating a new session.id before calling the real + # `authenticate` method. + # But we need the session id in the `authenticate` method because + # we need to retrieve the visitor before the authentication, which + # require the session id. + res = self.url_open('/web/login') + csrf_anchor = ' The ``access_token`` value should also be updated from 1 to 2. + 2. There is a visitor for both partners and partner 1 is merged into + partner 2. + -> Both visitor should be merged too, so data are aggregated into a + single visitor. + + Case 1 is tested here. + Cade 2 is tested in :meth:`test_merge_partner_with_visitor_both`. + """ + # Setup a visitor for admin_duplicate and none for admin + Visitor = self.env['website.visitor'] + (self.partner_admin_duplicate + self.partner_admin).visitor_ids.unlink() + visitor_admin_duplicate = Visitor.create({ + 'partner_id': self.partner_admin_duplicate.id, + 'access_token': self.partner_admin_duplicate.id, + }) + # | id | access_token | partner_id | + # | -- | ---------------------- | --------------------- | + # | 1 | admin_duplicate_id | admin_duplicate_id | + # | | 1062141 | 1062141 | + self.assertTrue(visitor_admin_duplicate.partner_id.id == + int(visitor_admin_duplicate.access_token) == + self.partner_admin_duplicate.id) + + # Merge admin_duplicate partner (no user associated) in admin partner + self.env['base.partner.merge.automatic.wizard']._merge( + (self.partner_admin + self.partner_admin_duplicate).ids, + self.partner_admin + ) + # This should not happen.. + # | id | access_token | partner_id | + # | -- | ---------------------- | ---------- | + # | 1 | admin_duplicate_id | admin_id | <-- Mismatch + # | | 1062141 | 5013266 | + # .. it should be: + # | id | access_token | partner_id | + # | -- | ------------ | ---------- | + # | 1 | admin_id | admin_id | <-- No mismatch, became admin_id + # | | 5013266 | 5013266 | + self.assertTrue(visitor_admin_duplicate.partner_id.id == + int(visitor_admin_duplicate.access_token) == + self.partner_admin.id, + "The admin_duplicate visitor should now be linked to the admin partner.") + self.assertFalse(Visitor.search_count([('partner_id', '=', self.partner_admin_duplicate.id)]), + "The admin_duplicate visitor should've been merged (and deleted) with the admin one.") diff --git a/tools.py b/tools.py new file mode 100644 index 0000000..debfb11 --- /dev/null +++ b/tools.py @@ -0,0 +1,221 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import contextlib +import re +import werkzeug.urls +from lxml import etree +from unittest.mock import Mock, MagicMock, patch + +from werkzeug.exceptions import NotFound +from werkzeug.test import EnvironBuilder + +import odoo +from odoo.tests.common import HttpCase, HOST +from odoo.tools.misc import hmac, DotDict, frozendict + + +@contextlib.contextmanager +def MockRequest( + env, *, path='/mockrequest', routing=True, multilang=True, + context=frozendict(), cookies=frozendict(), country_code=None, + website=None, remote_addr=HOST, environ_base=None, + # website_sale + sale_order_id=None, website_sale_current_pl=None, +): + + lang_code = context.get('lang', env.context.get('lang', 'en_US')) + env = env(context=dict(context, lang=lang_code)) + request = Mock( + # request + httprequest=Mock( + host='localhost', + path=path, + app=odoo.http.root, + environ=dict( + EnvironBuilder( + path=path, + base_url=HttpCase.base_url(), + environ_base=environ_base, + ).get_environ(), + REMOTE_ADDR=remote_addr, + ), + cookies=cookies, + referrer='', + remote_addr=remote_addr, + ), + type='http', + future_response=odoo.http.FutureResponse(), + params={}, + redirect=env['ir.http']._redirect, + session=DotDict( + odoo.http.get_default_session(), + geoip={'country_code': country_code}, + sale_order_id=sale_order_id, + website_sale_current_pl=website_sale_current_pl, + ), + geoip=odoo.http.GeoIP('127.0.0.1'), + db=env.registry.db_name, + env=env, + registry=env.registry, + cr=env.cr, + uid=env.uid, + context=env.context, + lang=env['res.lang']._lang_get(lang_code), + website=website, + render=lambda *a, **kw: '', + ) + if website: + request.website_routing = website.id + + # The following code mocks match() to return a fake rule with a fake + # 'routing' attribute (routing=True) or to raise a NotFound + # exception (routing=False). + # + # router = odoo.http.root.get_db_router() + # rule, args = router.bind(...).match(path) + # # arg routing is True => rule.endpoint.routing == {...} + # # arg routing is False => NotFound exception + router = MagicMock() + match = router.return_value.bind.return_value.match + if routing: + match.return_value[0].routing = { + 'type': 'http', + 'website': True, + 'multilang': multilang + } + else: + match.side_effect = NotFound + + def update_context(**overrides): + request.context = dict(request.context, **overrides) + + request.update_context = update_context + + with contextlib.ExitStack() as s: + odoo.http._request_stack.push(request) + s.callback(odoo.http._request_stack.pop) + s.enter_context(patch('odoo.http.root.get_db_router', router)) + + yield request + +# Fuzzy matching tools + +def distance(s1="", s2="", limit=4): + """ + Limited Levenshtein-ish distance (inspired from Apache text common) + Note: this does not return quick results for simple cases (empty string, equal strings) + those checks should be done outside loops that use this function. + + :param s1: first string + :param s2: second string + :param limit: maximum distance to take into account, return -1 if exceeded + + :return: number of character changes needed to transform s1 into s2 or -1 if this exceeds the limit + """ + BIG = 100000 # never reached integer + if len(s1) > len(s2): + s1, s2 = s2, s1 + l1 = len(s1) + l2 = len(s2) + if l2 - l1 > limit: + return -1 + boundary = min(l1, limit) + 1 + p = [i if i < boundary else BIG for i in range(0, l1 + 1)] + d = [BIG for _ in range(0, l1 + 1)] + for j in range(1, l2 + 1): + j2 = s2[j - 1] + d[0] = j + range_min = max(1, j - limit) + range_max = min(l1, j + limit) + if range_min > 1: + d[range_min - 1] = BIG + for i in range(range_min, range_max + 1): + if s1[i - 1] == j2: + d[i] = p[i - 1] + else: + d[i] = 1 + min(d[i - 1], p[i], p[i - 1]) + p, d = d, p + return p[l1] if p[l1] <= limit else -1 + +def similarity_score(s1, s2): + """ + Computes a score that describes how much two strings are matching. + + :param s1: first string + :param s2: second string + + :return: float score, the higher the more similar + pairs returning non-positive scores should be considered non similar + """ + dist = distance(s1, s2) + if dist == -1: + return -1 + set1 = set(s1) + score = len(set1.intersection(s2)) / len(set1) + score -= dist / len(s1) + score -= len(set1.symmetric_difference(s2)) / (len(s1) + len(s2)) + return score + +def text_from_html(html_fragment, collapse_whitespace=False): + """ + Returns the plain non-tag text from an html + + :param html_fragment: document from which text must be extracted + + :return: text extracted from the html + """ + # lxml requires one single root element + tree = etree.fromstring('

%s

' % html_fragment, etree.XMLParser(recover=True)) + content = ' '.join(tree.itertext()) + if collapse_whitespace: + content = re.sub('\\s+', ' ', content).strip() + return content + +def get_base_domain(url, strip_www=False): + """ + Returns the domain of a given url without the scheme and the www. and the + final '/' if any. + + :param url: url from which the domain must be extracted + :param strip_www: if True, strip the www. from the domain + + :return: domain of the url + """ + if not url: + return '' + + url = werkzeug.urls.url_parse(url).netloc + if strip_www and url.startswith('www.'): + url = url[4:] + return url + + +def add_form_signature(html_fragment, env_sudo): + for form in html_fragment.iter('form'): + if '/website/form/' not in form.attrib.get('action', ''): + continue + + existing_hash_node = form.find('.//input[@type="hidden"][@name="website_form_signature"]') + if existing_hash_node is not None: + existing_hash_node.getparent().remove(existing_hash_node) + input_nodes = form.xpath('.//input[contains(@name, "email_")]') + form_values = {input_node.attrib['name']: input_node for input_node in input_nodes} + # if this form does not send an email, ignore. But at this stage, + # the value of email_to can still be None in case of default value + if 'email_to' not in form_values: + continue + + email_to_value = form_values['email_to'].attrib.get('value') + if (not email_to_value + or (email_to_value == 'info@yourcompany.example.com' + and html_fragment.xpath('//span[@data-for="contactus_form"]'))): + # This means that the mail will be sent to the value of the dataFor + # which is the company email. + email_to_value = env_sudo.company.email or '' + + has_cc = {'email_cc', 'email_bcc'} & form_values.keys() + value = email_to_value + (':email_cc' if has_cc else '') + hash_value = hmac(env_sudo, 'website_form_signature', value) + if has_cc: + hash_value += ':email_cc' + hash_node = etree.Element('input', attrib={'type': "hidden", 'value': hash_value, 'class': "form-control s_website_form_input s_website_form_custom", 'name': "website_form_signature"}) + form_values['email_to'].addnext(hash_node) diff --git a/views/ir_actions_server_views.xml b/views/ir_actions_server_views.xml new file mode 100644 index 0000000..5117582 --- /dev/null +++ b/views/ir_actions_server_views.xml @@ -0,0 +1,51 @@ + + + + + + ir.actions.server.form.website + ir.actions.server + + + + + + + + + + + + + + + + + ir.actions.server.tree.website + ir.actions.server + + + + + + + + + + ir.actions.server.search.website + ir.actions.server + + + + + + + + + + diff --git a/views/ir_asset_views.xml b/views/ir_asset_views.xml new file mode 100644 index 0000000..eef7b30 --- /dev/null +++ b/views/ir_asset_views.xml @@ -0,0 +1,23 @@ + + + + ir.asset.form.inherit.website + ir.asset + + + + + + + + + ir.asset.tree.inherit.website + ir.asset + + + + + + + + diff --git a/views/ir_attachment_views.xml b/views/ir_attachment_views.xml new file mode 100644 index 0000000..f2ba6fc --- /dev/null +++ b/views/ir_attachment_views.xml @@ -0,0 +1,23 @@ + + + + ir.attachment.form.inherit.website + ir.attachment + + + + + + + + + ir.attachment.tree.inherit.website + ir.attachment + + + + + + + + diff --git a/views/ir_model_views.xml b/views/ir_model_views.xml new file mode 100644 index 0000000..567e06a --- /dev/null +++ b/views/ir_model_views.xml @@ -0,0 +1,37 @@ + + + + + website.ir.model.view.form + ir.model + + + + + + + + + + + + + + + + + + + + website.ir.model.fields.view.form + ir.model.fields + + + + + + + + + + diff --git a/views/neutralize_views.xml b/views/neutralize_views.xml new file mode 100644 index 0000000..67f431b --- /dev/null +++ b/views/neutralize_views.xml @@ -0,0 +1,29 @@ + + + + diff --git a/views/new_page_template_templates.xml b/views/new_page_template_templates.xml new file mode 100644 index 0000000..8775d77 --- /dev/null +++ b/views/new_page_template_templates.xml @@ -0,0 +1,1075 @@ + + + + + + + + + + + + + + +