',
+ 'key': 'test.specific_view',
+ })
+ self.page_specific = Page.create({
+ 'view_id': self.specific_view.id,
+ 'url': '/page_specific',
+ 'website_id': 1,
+ })
+ self.page_specific_menu = Menu.create({
+ 'name': 'Page Specific menu',
+ 'page_id': self.page_specific.id,
+ 'website_id': 1,
+ })
+ total_pages = Page.search_count([])
+ total_menus = Menu.search_count([])
+ # Copying a specific page should create a new page with an unique URL (suffixed by -X)
+ Page.clone_page(self.page_specific.id, clone_menu=True)
+ cloned_page = Page.search([('url', '=', '/page_specific-1')])
+ cloned_menu = Menu.search([('url', '=', '/page_specific-1')])
+ self.assertEqual(len(cloned_page), 1, "A page with an URL /page_specific-1 should've been created")
+ self.assertEqual(Page.search_count([]), total_pages + 1, "Should have cloned the page")
+ # It should also copy its menu with new url/name/page_id (if the page has a menu)
+ self.assertEqual(len(cloned_menu), 1, "A specific page (with a menu) being cloned should have it's menu also cloned")
+ self.assertEqual(cloned_menu.page_id, cloned_page, "The new cloned menu and the new cloned page should be linked (m2o)")
+ self.assertEqual(Menu.search_count([]), total_menus + 1, "Should have cloned the page menu")
+ Page.clone_page(self.page_specific.id, page_name="about-us", clone_menu=True)
+ cloned_page_about_us = Page.search([('url', '=', '/about-us')])
+ cloned_menu_about_us = Menu.search([('url', '=', '/about-us')])
+ self.assertEqual(len(cloned_page_about_us), 1, "A page with an URL /about-us should've been created")
+ self.assertEqual(len(cloned_menu_about_us), 1, "A specific page (with a menu) being cloned should have it's menu also cloned")
+ self.assertEqual(cloned_menu_about_us.page_id, cloned_page_about_us, "The new cloned menu and the new cloned page should be linked (m2o)")
+ # It should also copy its menu with new url/name/page_id (if the page has a menu)
+ self.assertEqual(Menu.search_count([]), total_menus + 2, "Should have cloned the page menu")
+
+ total_pages = Page.search_count([])
+ total_menus = Menu.search_count([])
+
+ # Copying a generic page should create a specific page with same URL
+ Page.clone_page(self.page_1.id, clone_menu=True)
+ cloned_generic_page = Page.search([('url', '=', '/page_1'), ('id', '!=', self.page_1.id), ('website_id', '!=', False)])
+ self.assertEqual(len(cloned_generic_page), 1, "A generic page being cloned should create a specific one for the current website")
+ self.assertEqual(cloned_generic_page.url, self.page_1.url, "The URL of the cloned specific page should be the same as the generic page it has been cloned from")
+ self.assertEqual(Page.search_count([]), total_pages + 1, "Should have cloned the generic page as a specific page for this website")
+ self.assertEqual(Menu.search_count([]), total_menus, "It should not create a new menu as the generic page's menu belong to another website")
+ # Except if the URL already exists for this website (its the case now that we already cloned it once)
+ Page.clone_page(self.page_1.id, clone_menu=True)
+ cloned_generic_page_2 = Page.search([('url', '=', '/page_1-1'), ('id', '!=', self.page_1.id)])
+ self.assertEqual(len(cloned_generic_page_2), 1, "A generic page being cloned should create a specific page with a new URL if there is already a specific page with that URL")
+
+ def test_cow_page(self):
+ Menu = self.env['website.menu']
+ Page = self.env['website.page']
+ View = self.env['ir.ui.view']
+
+ # backend write, no COW
+ total_pages = Page.search_count([])
+ total_menus = Menu.search_count([])
+ total_views = View.search_count([])
+ self.page_1.write({'arch': '
'})
+
+ # 1. should have created website-specific copies for:
+ # - page
+ # - view x2 (base view + extension view)
+ # 2. should not have created menu copy as menus are not shared/COW
+ # 3. and shouldn't have touched original records
+ self.assertEqual(total_pages + 1, Page.search_count([]))
+ self.assertEqual(total_menus, Menu.search_count([]))
+ self.assertEqual(total_views + 2, View.search_count([]))
+
+ self.assertEqual(self.page_1.arch, '
')
+
+ def test_cow_extension_view(self):
+ ''' test cow on extension view itself (like web_editor would do in the frontend) '''
+ Menu = self.env['website.menu']
+ Page = self.env['website.page']
+ View = self.env['ir.ui.view']
+
+ # nothing special should happen when editing through the backend
+ total_pages = Page.search_count([])
+ total_menus = Menu.search_count([])
+ total_views = View.search_count([])
+ self.extension_view.write({'arch': '
')
+ self.assertEqual(total_pages, Page.search_count([]))
+ self.assertEqual(total_menus, Menu.search_count([]))
+ self.assertEqual(total_views, View.search_count([]))
+
+ # When editing through the frontend a website-specific copy
+ # for the extension view should be created. When rendering the
+ # original website.page on website 1 it will look differently
+ # due to this new extension view.
+ self.extension_view.with_context(website_id=1).write({'arch': '
')
+ # read on website should reflect change
+ arch = self.base_view.with_context(website_id=1).read_combined(['arch'])['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).read_combined(['arch'])['arch']
+ self.assertEqual(arch, '
')
+
+ # # 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.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).read_combined(['arch'])['arch']
+ self.assertEqual('Hi World' in arch, True)
+
+ self.base_view.write({'arch': '
'})
+
+ # id | name | content | website_id | inherit | key
+ # -------------------------------------------------------
+ # 1 | Base | Hello | / | / | website.base_view
+ # 3 | Base | Bye | 1 | / | website.base_view
+ # 2 | Extension | World | / | 1 | website.extension_view
+ # 4 | Extension | Nobody | 1 | 3 | website.extension_view
+
+ arch = base_specific.with_context(website_id=1).read_combined(['arch'])['arch']
+ self.assertEqual('Bye Nobody' in arch, True, "Write on generic `inherit_view` should have been diverted to already existing specific view")
+
+ base_arch = self.base_view.read_combined(['arch'])['arch']
+ base_arch_w1 = self.base_view.with_context(website_id=1).read_combined(['arch'])['arch']
+ self.assertEqual('Hello World' in base_arch, True)
+ self.assertEqual(base_arch, base_arch_w1, "Reading a top level view with or without a website_id in the context should render that exact view..") # ..even if there is a specific view for that one, as read_combined is supposed to render specific inherited view over generic but not specific top level instead of generic top level
+
+ def test_cow_cross_inherit(self):
+ View = self.env['ir.ui.view']
+ total_views = View.search_count([])
+
+ main_view = View.create({
+ 'name': 'Main View',
+ 'type': 'qweb',
+ 'arch': 'GENERIC
',
+ '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).read_combined(['arch'])['arch']
+ specific_view_arch = View.browse(specific_view).with_context(load_all_views=True, website_id=1).read_combined(['arch'])['arch']
+ self.assertEqual(generic_view_arch, 'GENERIC
', "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': '''
+
+ ''',
+ })
+
+ views = View.with_context(website_id=1).get_related_views('_website_sale.products')
+ self.assertEqual(views, products + products_item + add_to_wishlist + products_list_view, "The four views should be returned.")
+ add_to_wishlist.with_context(website_id=1).write({'active': False}) # Trigger cow on hierarchy
+ add_to_wishlist_cow = Website.with_context(website_id=1).viewref(add_to_wishlist.key)
+ views = View.with_context(website_id=1).get_related_views('_website_sale.products')
+ self.assertEqual(views, products + products_item + add_to_wishlist_cow + products_list_view, "The generic wishlist view should have been replaced by the COW one.")
+
+ def test_cow_inherit_children_order(self):
+ """ COW method should loop on inherit_children_ids in correct order
+ when copying them on the new specific tree.
+ Correct order is the same as the one when applying view arch:
+ PRIORITY, ID
+ And not the default one from ir.ui.view (NAME, PRIORIRTY, ID).
+ """
+ self.inherit_view.copy({
+ 'name': 'alphabetically before "Extension"',
+ 'key': '_test.alphabetically_first',
+ 'arch': '
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_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',
+ })])
+ Website.with_context(load_all_views=True).viewref('_website_sale_comparison.product_add_to_compare').invalidate_cache()
+
+ # Simulate end of installation/update
+ View._create_all_specific_views(['_website_sale_comparison'])
+
+ specific_view = Website.with_context(load_all_views=True, website_id=1).viewref('_website_sale.product')
+ self.assertEqual(self.base_view.key, specific_view.key, "Ensure it is equal as it should be for the rest of the test so we test the expected behaviors")
+ specific_view_arch = specific_view.read_combined(['arch'])['arch']
+ self.assertEqual(specific_view.website_id.id, 1, "Ensure we got specific view to perform the checks against")
+ self.assertEqual(specific_view_arch, '
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': '
', "When a module updates an inherited view (on a generic tree), it should also update the copies of that view (COW).")
+
+ # Test fields that should not be COW'd
+ random_views = View.search([('key', '!=', None)], limit=2)
+ View._load_records([dict(xml_id='_website_sale_comparison.product_add_to_compare', values={
+ 'website_id': None,
+ 'inherit_id': random_views[0].id,
+ })])
+
+ w1_specific_child_view = Website.with_context(load_all_views=True, website_id=1).viewref('_website_sale_comparison.product_add_to_compare')
+ generic_child_view = Website.with_context(load_all_views=True).viewref('_website_sale_comparison.product_add_to_compare')
+ self.assertEqual(w1_specific_child_view.website_id.id, 1, "website_id is a prohibited field when COWing views during _load_records")
+ self.assertEqual(generic_child_view.inherit_id, random_views[0], "prohibited fields only concerned write on COW'd view. Generic should still considere these fields")
+ self.assertEqual(w1_specific_child_view.inherit_id, random_views[0], "inherit_id update should be repliacated on cow views during _load_records")
+
+ # Set back the generic view as parent for the rest of the test
+ generic_child_view.inherit_id = self.base_view
+ w1_specific_child_view.inherit_id = specific_view
+
+ # Don't update inherit_id if it was anually updated
+ w1_specific_child_view.inherit_id = random_views[1].id
+ View._load_records([dict(xml_id='_website_sale_comparison.product_add_to_compare', values={
+ 'inherit_id': random_views[0].id,
+ })])
+ self.assertEqual(w1_specific_child_view.inherit_id, random_views[1],
+ "inherit_id update should not be repliacated on cow views during _load_records if it was manually updated before")
+
+ # Set back the generic view as parent for the rest of the test
+ generic_child_view.inherit_id = self.base_view
+ w1_specific_child_view.inherit_id = specific_view
+
+ # Don't update fields from COW'd view if these fields have been modified from original view
+ new_website = Website.create({'name': 'New Website'})
+ self.base_view.with_context(website_id=new_website.id).write({'name': 'Product (new_website)'})
+ new_website_specific_child_view = Website.with_context(load_all_views=True, website_id=new_website.id).viewref('_website_sale_comparison.product_add_to_compare')
+ new_website_specific_child_view.priority = 6
+ View._load_records([dict(xml_id='_website_sale_comparison.product_add_to_compare', values={
+ 'priority': 3,
+ })])
+ self.assertEqual(generic_child_view.priority, 3, "XML update should be written on the Generic View")
+ self.assertEqual(w1_specific_child_view.priority, 3, "XML update should be written on the specific view if the fields have not been modified on that specific view")
+ self.assertEqual(new_website_specific_child_view.priority, 6, "XML update should NOT be written on the specific view if the fields have been modified on that specific view")
+
+ # Simulate website_sale update on top level view
+ self._create_imd(self.base_view)
+ self.base_view.invalidate_cache()
+ View._load_records([dict(xml_id='_website_sale.product', values={
+ 'website_meta_title': 'A bug got fixed by updating this field',
+ })])
+ all_title_updated = specific_view.website_meta_title == self.base_view.website_meta_title == "A bug got fixed by updating this field"
+ self.assertEqual(all_title_updated, True, "Update on top level generic views should also be applied on specific views")
+
+ def test_module_new_inherit_view_on_parent_already_forked_xpath_replace(self):
+ """ Deeper, more specific test of above behavior.
+ A module install should add/update the COW view (if allowed fields,
+ eg not modified or prohibited (website_id, inherit_id..)).
+ This test ensure it does not crash if the child view is a primary view.
+ """
+ View = self.env['ir.ui.view']
+
+ # Simulate layout views
+ base_view = View.create({
+ 'name': 'Main Frontend Layout',
+ 'type': 'qweb',
+ 'arch': '',
+ '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': '
',
+ "copy on write (COW) also copy existing translations")
+
+ translation.value = 'hi'
+ self.assertEqual(specific_view.with_context(lang='en_US').arch, '
hello
',
+ "updating translation of base view doesn't update specific view")
+
+ Translation._load_module_terms(['website'], ['en_US'], overwrite=True)
+
+ specific_view.invalidate_cache(['arch_db', 'arch'])
+ self.assertEqual(specific_view.with_context(lang='en_US').arch, '
hi
',
+ "loading module translation copy translation from base to specific view")
+
+ def test_specific_view_module_update_inherit_change(self):
+ """ During a module update, if inherit_id is changed, we need to
+ replicate the change for cow views. """
+ # If D.inherit_id becomes B instead of A, after module update, we expect:
+ # CASE 1
+ # A A' B A A' B
+ # | | => / \
+ # D D' D D'
+ #
+ # CASE 2
+ # A A' B B' A A' B B'
+ # | | => | |
+ # D D' D D'
+ #
+ # CASE 3
+ # A B A B
+ # / \ => / \
+ # D D' D D'
+ #
+ # CASE 4
+ # A B B' A B B'
+ # / \ => | |
+ # D D' D D'
+
+ # 1. Setup following view trees
+ # A A' B
+ # | |
+ # D D'
+ View = self.env['ir.ui.view']
+ Website = self.env['website']
+ self._create_imd(self.inherit_view)
+ # invalidate cache to recompute xml_id, or it will still be empty
+ self.inherit_view.invalidate_cache()
+ base_view_2 = self.base_view.copy({'key': 'website.base_view2', 'arch': '
'})
+ specific_child_view = Website.with_context(load_all_views=True, website_id=1).viewref(self.inherit_view.key)
+ # 2. Ensure view trees are as expected
+ self.assertEqual(self.base_view.inherit_children_ids, self.inherit_view, "D should be under A")
+ self.assertEqual(specific_view.inherit_children_ids, specific_child_view, "D' should be under A'")
+ self.assertFalse(base_view_2.inherit_children_ids, "B should have no child")
+
+ # 3. Simulate module update, D.inherit_id is now B instead of A
+ View._load_records([dict(xml_id=self.inherit_view.key, values={
+ 'inherit_id': base_view_2.id,
+ })])
+
+ # 4. Ensure view trees is now
+ # A A' B
+ # / \
+ # D D'
+ self.assertTrue(len(self.base_view.inherit_children_ids) == len(specific_view.inherit_children_ids) == 0,
+ "Child views should now be under view B")
+ self.assertEqual(len(base_view_2.inherit_children_ids), 2, "D and D' should be under B")
+ self.assertTrue(self.inherit_view in base_view_2.inherit_children_ids, "D should be under B")
+ self.assertTrue(specific_child_view in base_view_2.inherit_children_ids, "D' should be under B")
+
+
+@tagged('-at_install', 'post_install')
+class Crawler(HttpCase):
+ def setUp(self):
+ super(Crawler, self).setUp()
+ View = self.env['ir.ui.view']
+
+ self.base_view = View.create({
+ 'name': 'Base',
+ 'type': 'qweb',
+ 'arch': '
',
+ 'key': 'website.extension_view',
+ })
+
+ def test_get_switchable_related_views(self):
+ View = self.env['ir.ui.view']
+ Website = self.env['website']
+
+ # Set up
+ website_1 = Website.create({'name': 'Website 1'}) # will have specific views
+ website_2 = Website.create({'name': 'Website 2'}) # will use generic views
+
+ self.base_view.write({'name': 'Main Frontend Layout', 'key': '_portal.frontend_layout'})
+ event_main_view = self.base_view.copy({
+ 'name': 'Events',
+ 'key': '_website_event.index',
+ 'arch': '
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'})
+
+ View.flush()
+
+ # Customize
+ # | Main Frontend Layout
+ # | Show Sign In
+ # | Main Layout
+ # | Affix Top Menu
+ # | Show Logo
+ # | Events
+ # | Filters
+ # | Photos
+ # | Quotes
+ # | Filters
+ # | Filter By Category
+ # | Filter By Country
+
+ self.authenticate("admin", "admin")
+ base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+
+ # Simulate website 2 (that use only generic views)
+ self.url_open(base_url + '/website/force/%s' % website_2.id)
+
+ # Test controller
+ url = base_url + '/website/get_switchable_related_views'
+ json = {'params': {'key': '_website_event.index'}}
+ response = self.opener.post(url=url, json=json)
+ res = response.json()['result']
+
+ self.assertEqual(
+ [v['name'] for v in res],
+ ['Sign In', 'Affix Top Menu', 'Show Logo', 'Filters', 'Photos', 'Quotes', 'Filter by Category', 'Filter by Country'],
+ "Sequence should not be taken into account for customize menu",
+ )
+ self.assertEqual(
+ [v['inherit_id'][1] for v in res],
+ ['Main Frontend Layout', 'Main layout', 'Main layout', 'Events', 'Events', 'Events', 'Filters', 'Filters'],
+ "Sequence should not be taken into account for customize menu (Checking Customize headers)",
+ )
+
+ # Trigger COW
+ view_logo.with_context(website_id=website_1.id).write({'arch': '
, 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 = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+
+ # Simulate website 2
+ self.url_open(base_url + '/website/force/%s' % website_2.id)
+
+ # Test controller
+ url = base_url + '/website/get_switchable_related_views'
+ json = {'params': {'key': '_website_sale.products'}}
+ response = self.opener.post(url=url, json=json)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(response.json()['result']), 1, "Only '_theme_kea_sale.products' should be returned as it is the only customize_show related view in website 2 context")
+ self.assertEqual(response.json()['result'][0]['key'], '_theme_kea_sale.products', "Only '_theme_kea_sale.products' should be returned")
+
+ # Simulate website 1
+ self.url_open(base_url + '/website/force/%s' % website_1.id)
+
+ # Test controller
+ url = base_url + '/website/get_switchable_related_views'
+ json = {'params': {'key': '_website_sale.products'}}
+ response = self.opener.post(url=url, json=json)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(response.json()['result']), 1, "Only '_website_sale.child_view_w1' should be returned as it is the only customize_show related view in website 1 context")
+ self.assertEqual(response.json()['result'][0]['key'], '_website_sale.child_view_w1', "Only '_website_sale.child_view_w1' should be returned")
+
+
+@tagged('post_install', '-at_install')
+class TestThemeViews(common.TransactionCase):
+ def test_inherit_specific(self):
+ View = self.env['ir.ui.view']
+ Website = self.env['website']
+
+ website_1 = Website.create({'name': 'Website 1'})
+
+ # 1. Simulate COW structure
+ main_view = View.create({
+ 'name': 'Test Main View',
+ 'type': 'qweb',
+ 'arch': '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`
+ 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/addons/website/tests/test_views_inherit_module_update.py b/addons/website/tests/test_views_inherit_module_update.py
new file mode 100644
index 00000000..04d5d938
--- /dev/null
+++ b/addons/website/tests/test_views_inherit_module_update.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import standalone
+
+"""
+This test ensure `inherit_id` update is correctly replicated on cow views.
+The view receiving the `inherit_id` update is either:
+1. in a module loaded before `website`. In that case, `website` code is not
+ loaded yet, so we store the updates to replay the changes on the cow views
+ once `website` module is loaded (see `_check()`). This test is testing that
+ part.
+2. in a module loaded after `website`. In that case, the `inherit_id` update is
+ directly replicated on the cow views. That behavior is tested with
+ `test_module_new_inherit_view_on_parent_already_forked` and
+ `test_specific_view_module_update_inherit_change` in `website` module.
+"""
+
+
+@standalone('cow_views_inherit')
+def test_01_cow_views_inherit_on_module_update(env):
+ # A B A B
+ # / \ => / \
+ # D D' D D'
+
+ # 1. Setup hierarchy as comment above
+ View = env['ir.ui.view']
+ View.with_context(_force_unlink=True, active_test=False).search([('website_id', '=', 1)]).unlink()
+ child_view = env.ref('portal.footer_language_selector')
+ parent_view = env.ref('portal.portal_back_in_edit_mode')
+ # Change `inherit_id` so the module update will set it back to the XML value
+ child_view.write({'inherit_id': parent_view.id, 'arch': child_view.arch_db.replace('o_footer_copyright_name', 'text-center')})
+ # Trigger COW on view
+ child_view.with_context(website_id=1).write({'name': 'COW Website 1'})
+ child_cow_view = child_view._get_specific_views()
+
+ # 2. Ensure setup is as expected
+ assert child_cow_view.inherit_id == parent_view, "Ensure test is setup as expected."
+
+ # 3. Upgrade the module
+ portal_module = env['ir.module.module'].search([('name', '=', 'portal')])
+ portal_module.button_immediate_upgrade()
+ env.reset() # clear the set of environments
+ env = env() # get an environment that refers to the new registry
+
+ # 4. Ensure cow view also got its inherit_id updated
+ expected_parent_view = env.ref('portal.frontend_layout') # XML data
+ assert child_view.inherit_id == expected_parent_view, "Generic view security check."
+ assert child_cow_view.inherit_id == expected_parent_view, "COW view should also have received the `inherit_id` update."
+
+
+@standalone('cow_views_inherit')
+def test_02_cow_views_inherit_on_module_update(env):
+ # A B B' A B B'
+ # / \ => | |
+ # D D' D D'
+
+ # 1. Setup hierarchy as comment above
+ View = env['ir.ui.view']
+ View.with_context(_force_unlink=True, active_test=False).search([('website_id', '=', 1)]).unlink()
+ view_D = env.ref('portal.my_account_link')
+ view_A = env.ref('portal.message_thread')
+ # Change `inherit_id` so the module update will set it back to the XML value
+ view_D.write({'inherit_id': view_A.id, 'arch_db': view_D.arch_db.replace('o_logout_divider', 'discussion')})
+ # Trigger COW on view
+ view_B = env.ref('portal.user_dropdown') # XML data
+ view_D.with_context(website_id=1).write({'name': 'D Website 1'})
+ view_B.with_context(website_id=1).write({'name': 'B Website 1'})
+ view_Dcow = view_D._get_specific_views()
+
+ # 2. Ensure setup is as expected
+ view_Bcow = view_B._get_specific_views()
+ assert view_Dcow.inherit_id == view_A, "Ensure test is setup as expected."
+ assert len(view_Bcow) == len(view_Dcow) == 1, "Ensure test is setup as expected (2)."
+ assert view_B != view_Bcow, "Security check to ensure `_get_specific_views` return what it should."
+
+ # 3. Upgrade the module
+ portal_module = env['ir.module.module'].search([('name', '=', 'portal')])
+ portal_module.button_immediate_upgrade()
+ env.reset() # clear the set of environments
+ env = env() # get an environment that refers to the new registry
+
+ # 4. Ensure cow view also got its inherit_id updated
+ assert view_D.inherit_id == view_B, "Generic view security check."
+ assert view_Dcow.inherit_id == view_Bcow, "COW view should also have received the `inherit_id` update."
diff --git a/addons/website/tests/test_website_favicon.py b/addons/website/tests/test_website_favicon.py
new file mode 100644
index 00000000..3346ccd4
--- /dev/null
+++ b/addons/website/tests/test_website_favicon.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from PIL import Image
+
+from odoo.tests import tagged
+from odoo.tests.common import TransactionCase
+from odoo.tools import base64_to_image, image_to_base64
+
+
+@tagged('post_install', '-at_install')
+class TestWebsiteResetPassword(TransactionCase):
+
+ def test_01_website_favicon(self):
+ """The goal of this test is to make sure the favicon is correctly
+ handled on the website."""
+
+ # Test setting an Ico file directly, done through create
+ Website = self.env['website']
+
+ website = Website.create({
+ 'name': 'Test Website',
+ 'favicon': Website._default_favicon(),
+ })
+
+ image = base64_to_image(website.favicon)
+ self.assertEqual(image.format, 'ICO')
+
+ # Test setting a JPEG file that is too big, done through write
+ bg_color = (135, 90, 123)
+ image = Image.new('RGB', (1920, 1080), color=bg_color)
+ website.favicon = image_to_base64(image, 'JPEG')
+ image = base64_to_image(website.favicon)
+ self.assertEqual(image.format, 'ICO')
+ self.assertEqual(image.size, (256, 256))
+ self.assertEqual(image.getpixel((0, 0)), bg_color)
diff --git a/addons/website/tests/test_website_reset_password.py b/addons/website/tests/test_website_reset_password.py
new file mode 100644
index 00000000..83e23a56
--- /dev/null
+++ b/addons/website/tests/test_website_reset_password.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from unittest.mock import patch
+
+import odoo
+from odoo.tests import tagged
+from odoo.tests.common import HttpCase
+
+
+@tagged('post_install', '-at_install')
+class TestWebsiteResetPassword(HttpCase):
+
+ def test_01_website_reset_password_tour(self):
+ """The goal of this test is to make sure the reset password works."""
+
+ # We override unlink because we don't want the email to be auto deleted
+ # if the send works.
+ MailMail = odoo.addons.mail.models.mail_mail.MailMail
+
+ # We override send_mail because in HttpCase on runbot we don't have an
+ # SMTP server, so if force_send is set, the test is going to fail.
+ MailTemplate = odoo.addons.mail.models.mail_template.MailTemplate
+ original_send_mail = MailTemplate.send_mail
+
+ def my_send_mail(*args, **kwargs):
+ kwargs.update(force_send=False)
+ return original_send_mail(*args, **kwargs)
+
+ with patch.object(MailMail, 'unlink', lambda self: None), patch.object(MailTemplate, 'send_mail', my_send_mail):
+ user = self.env['res.users'].create({
+ 'login': 'test',
+ 'name': 'The King',
+ 'email': 'noop@example.com',
+ })
+ website_1 = self.env['website'].browse(1)
+ website_2 = self.env['website'].browse(2)
+
+ website_1.domain = "my-test-domain.com"
+ website_2.domain = "https://domain-not-used.fr"
+
+ user.partner_id.website_id = 2
+ user.invalidate_cache() # invalidate get_base_url
+
+ user.action_reset_password()
+ self.assertIn(website_2.domain, user.signup_url)
+
+ user.invalidate_cache()
+
+ user.partner_id.website_id = 1
+ user.action_reset_password()
+ self.assertIn(website_1.domain, user.signup_url)
+
+ (website_1 + website_2).domain = ""
+
+ user.action_reset_password()
+ user.invalidate_cache()
+
+ self.start_tour(user.signup_url, 'website_reset_password', login=None)
+
+ def test_02_multi_user_login(self):
+ # In case Specific User Account is activated on a website, the same login can be used for
+ # several users. Make sure we can still log in if 2 users exist.
+ website = self.env["website"].get_current_website()
+ website.ensure_one()
+
+ # Use AAA and ZZZ as names since res.users are ordered by 'login, name'
+ user1 = self.env["res.users"].create(
+ {"website_id": False, "login": "bobo@mail.com", "name": "AAA", "password": "bobo@mail.com"}
+ )
+ user2 = self.env["res.users"].create(
+ {"website_id": website.id, "login": "bobo@mail.com", "name": "ZZZ", "password": "bobo@mail.com"}
+ )
+
+ # The most specific user should be selected
+ self.authenticate("bobo@mail.com", "bobo@mail.com")
+ self.assertEqual(self.session["uid"], user2.id)
diff --git a/addons/website/tests/test_website_visitor.py b/addons/website/tests/test_website_visitor.py
new file mode 100644
index 00000000..374594f9
--- /dev/null
+++ b/addons/website/tests/test_website_visitor.py
@@ -0,0 +1,326 @@
+# coding: utf-8
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from contextlib import contextmanager
+from datetime import datetime, timedelta
+from unittest.mock import patch
+
+from odoo.addons.base.tests.common import HttpCaseWithUserDemo
+from odoo.addons.website.tools import MockRequest
+from odoo.addons.website.models.website_visitor import WebsiteVisitor
+from odoo.tests import common, tagged
+
+
+class MockVisitor(common.BaseCase):
+
+ @contextmanager
+ def mock_visitor_from_request(self, force_visitor=False):
+
+ def _get_visitor_from_request(model, *args, **kwargs):
+ return force_visitor
+
+ with patch.object(WebsiteVisitor, '_get_visitor_from_request',
+ autospec=True, wraps=WebsiteVisitor,
+ side_effect=_get_visitor_from_request) as _get_visitor_from_request_mock:
+ yield
+
+
+@tagged('-at_install', 'post_install', 'website_visitor')
+class WebsiteVisitorTests(MockVisitor, HttpCaseWithUserDemo):
+
+ def setUp(self):
+ super(WebsiteVisitorTests, self).setUp()
+
+ self.website = self.env['website'].search([
+ ('company_id', '=', self.env.user.company_id.id)
+ ], limit=1)
+ self.cookies = {}
+
+ untracked_view = self.env['ir.ui.view'].create({
+ 'name': 'UntackedView',
+ 'type': 'qweb',
+ 'arch': '''
+
+ 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])],
+ })
+
+ def _get_last_visitor(self):
+ return self.env['website.visitor'].search([], limit=1, order="id DESC")
+
+ def assertPageTracked(self, visitor, page):
+ """ Check a page is in visitor tracking data """
+ self.assertIn(page, visitor.website_track_ids.page_id)
+ self.assertIn(page, visitor.page_ids)
+
+ def assertVisitorTracking(self, visitor, pages):
+ """ Check the whole tracking history of a visitor """
+ for page in pages:
+ self.assertPageTracked(visitor, page)
+ self.assertEqual(
+ len(visitor.website_track_ids),
+ len(pages)
+ )
+
+ def assertVisitorDeactivated(self, visitor, main_visitor):
+ """ Temporary method to check that a visitor has been de-activated / merged
+ with other visitor, notably in case of login (see User.authenticate() as
+ well as Visitor._link_to_visitor() ).
+
+ As final result depends on installed modules (see overrides) due to stable
+ improvements linked to EventOnline, this method contains a hack to avoid
+ doing too much overrides just for that behavior. """
+ if 'parent_id' in self.env['website.visitor']:
+ self.assertTrue(bool(visitor))
+ self.assertFalse(visitor.active)
+ self.assertTrue(main_visitor.active)
+ self.assertEqual(visitor.parent_id, main_visitor)
+ else:
+ self.assertFalse(visitor)
+ self.assertTrue(bool(main_visitor))
+
+ def test_visitor_creation_on_tracked_page(self):
+ """ Test various flows involving visitor creation and update. """
+ existing_visitors = self.env['website.visitor'].search([])
+ existing_tracks = self.env['website.track'].search([])
+ self.url_open(self.untracked_page.url)
+ self.url_open(self.tracked_page.url)
+ self.url_open(self.tracked_page.url)
+
+ new_visitor = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
+ new_track = self.env['website.track'].search([('id', 'not in', existing_tracks.ids)])
+ self.assertEqual(len(new_visitor), 1, "1 visitor should be created")
+ self.assertEqual(len(new_track), 1, "There should be 1 tracked page")
+ self.assertEqual(new_visitor.visit_count, 1)
+ self.assertEqual(new_visitor.website_track_ids, new_track)
+ self.assertVisitorTracking(new_visitor, self.tracked_page)
+
+ # ------------------------------------------------------------
+ # Admin connects
+ # ------------------------------------------------------------
+
+ self.cookies = {'visitor_uuid': new_visitor.access_token}
+ with MockRequest(self.env, website=self.website, cookies=self.cookies):
+ self.authenticate(self.user_admin.login, 'admin')
+
+ visitor_admin = new_visitor
+ # visit a page
+ self.url_open(self.tracked_page_2.url)
+
+ # check tracking and visitor / user sync
+ self.assertVisitorTracking(visitor_admin, self.tracked_page | self.tracked_page_2)
+ self.assertEqual(visitor_admin.partner_id, self.partner_admin)
+ self.assertEqual(visitor_admin.name, self.partner_admin.name)
+
+ # ------------------------------------------------------------
+ # Portal connects
+ # ------------------------------------------------------------
+
+ with MockRequest(self.env, website=self.website, cookies=self.cookies):
+ self.authenticate(self.user_portal.login, 'portal')
+
+ self.assertFalse(
+ self.env['website.visitor'].search([('id', 'not in', (existing_visitors | visitor_admin).ids)]),
+ "No extra visitor should be created")
+
+ # visit a page
+ self.url_open(self.tracked_page.url)
+ self.url_open(self.untracked_page.url)
+ self.url_open(self.tracked_page_2.url)
+ self.url_open(self.tracked_page_2.url) # 2 time to be sure it does not record twice
+
+ # new visitor is created
+ new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
+ self.assertEqual(len(new_visitors), 2, "One extra visitor should be created")
+ visitor_portal = new_visitors[0]
+ self.assertEqual(visitor_portal.partner_id, self.partner_portal)
+ self.assertEqual(visitor_portal.name, self.partner_portal.name)
+ self.assertVisitorTracking(visitor_portal, self.tracked_page | self.tracked_page_2)
+
+ # ------------------------------------------------------------
+ # Back to anonymous
+ # ------------------------------------------------------------
+
+ # portal user disconnects
+ self.logout()
+
+ # visit some pages
+ self.url_open(self.tracked_page.url)
+ self.url_open(self.untracked_page.url)
+ self.url_open(self.tracked_page_2.url)
+ self.url_open(self.tracked_page_2.url) # 2 time to be sure it does not record twice
+
+ # new visitor is created
+ new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
+ self.assertEqual(len(new_visitors), 3, "One extra visitor should be created")
+ visitor_anonymous = new_visitors[0]
+ self.cookies['visitor_uuid'] = visitor_anonymous.access_token
+ self.assertFalse(visitor_anonymous.name)
+ self.assertFalse(visitor_anonymous.partner_id)
+ self.assertVisitorTracking(visitor_anonymous, self.tracked_page | self.tracked_page_2)
+ visitor_anonymous_tracks = visitor_anonymous.website_track_ids
+
+ # ------------------------------------------------------------
+ # Admin connects again
+ # ------------------------------------------------------------
+
+ with MockRequest(self.env, website=self.website, cookies=self.cookies):
+ self.authenticate(self.user_admin.login, 'admin')
+
+ # one visitor is deleted
+ visitor_anonymous = self.env['website.visitor'].with_context(active_test=False).search([('id', '=', visitor_anonymous.id)])
+ self.assertVisitorDeactivated(visitor_anonymous, visitor_admin)
+ new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
+ self.assertEqual(new_visitors, visitor_admin | visitor_portal)
+ visitor_admin = self.env['website.visitor'].search([('partner_id', '=', self.partner_admin.id)])
+ # tracks are linked
+ self.assertTrue(visitor_anonymous_tracks < visitor_admin.website_track_ids)
+ self.assertEqual(len(visitor_admin.website_track_ids), 4, "There should be 4 tracked page for the admin")
+
+ # ------------------------------------------------------------
+ # Back to anonymous
+ # ------------------------------------------------------------
+
+ # admin disconnects
+ self.logout()
+
+ # visit some pages
+ self.url_open(self.tracked_page.url)
+ self.url_open(self.untracked_page.url)
+ self.url_open(self.tracked_page_2.url)
+ self.url_open(self.tracked_page_2.url) # 2 time to be sure it does not record twice
+
+ # new visitor created
+ new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
+ self.assertEqual(len(new_visitors), 3, "One extra visitor should be created")
+ visitor_anonymous_2 = new_visitors[0]
+ self.cookies['visitor_uuid'] = visitor_anonymous_2.access_token
+ self.assertFalse(visitor_anonymous_2.name)
+ self.assertFalse(visitor_anonymous_2.partner_id)
+ self.assertVisitorTracking(visitor_anonymous_2, self.tracked_page | self.tracked_page_2)
+ visitor_anonymous_2_tracks = visitor_anonymous_2.website_track_ids
+
+ # ------------------------------------------------------------
+ # Portal connects again
+ # ------------------------------------------------------------
+ with MockRequest(self.env, website=self.website, cookies=self.cookies):
+ self.authenticate(self.user_portal.login, 'portal')
+
+ # one visitor is deleted
+ new_visitors = self.env['website.visitor'].search([('id', 'not in', existing_visitors.ids)])
+ self.assertEqual(new_visitors, visitor_admin | visitor_portal)
+ # tracks are linked
+ self.assertTrue(visitor_anonymous_2_tracks < visitor_portal.website_track_ids)
+ self.assertEqual(len(visitor_portal.website_track_ids), 4, "There should be 4 tracked page for the portal user")
+
+ # simulate the portal user comes back 30min later
+ for track in visitor_portal.website_track_ids:
+ track.write({'visit_datetime': track.visit_datetime - timedelta(minutes=30)})
+
+ # visit a page
+ self.url_open(self.tracked_page.url)
+ visitor_portal.invalidate_cache(fnames=['website_track_ids'])
+ # tracks are created
+ self.assertEqual(len(visitor_portal.website_track_ids), 5, "There should be 5 tracked page for the portal user")
+
+ # simulate the portal user comes back 8hours later
+ visitor_portal.write({'last_connection_datetime': visitor_portal.last_connection_datetime - timedelta(hours=8)})
+ self.url_open(self.tracked_page.url)
+ visitor_portal.invalidate_cache(fnames=['visit_count'])
+ # check number of visits
+ self.assertEqual(visitor_portal.visit_count, 2, "There should be 2 visits for the portal user")
+
+ def test_visitor_archive(self):
+ """ Test cron archiving inactive visitors and their re-activation when
+ authenticating an user. """
+ self.env['ir.config_parameter'].sudo().set_param('website.visitor.live.days', 7)
+
+ partner_demo = self.partner_demo
+ old_visitor = self.env['website.visitor'].create({
+ 'lang_id': self.env.ref('base.lang_en').id,
+ 'country_id': self.env.ref('base.be').id,
+ 'website_id': 1,
+ 'partner_id': partner_demo.id,
+ })
+ self.assertTrue(old_visitor.active)
+ self.assertEqual(partner_demo.visitor_ids, old_visitor, "Visitor and its partner should be synchronized")
+
+ # archive old visitor
+ old_visitor.last_connection_datetime = datetime.now() - timedelta(days=8)
+ self.env['website.visitor']._cron_archive_visitors()
+ self.assertEqual(old_visitor.active, False, "Visitor should be archived after inactivity")
+
+ # reconnect with new visitor.
+ self.url_open(self.tracked_page.url)
+ new_visitor = self._get_last_visitor()
+ self.assertFalse(new_visitor.partner_id)
+ self.assertTrue(new_visitor.id > old_visitor.id, "A new visitor should have been created.")
+ self.assertVisitorTracking(new_visitor, self.tracked_page)
+
+ with self.mock_visitor_from_request(force_visitor=new_visitor):
+ self.authenticate('demo', 'demo')
+ (new_visitor | old_visitor).flush()
+ partner_demo.flush()
+ partner_demo.invalidate_cache(fnames=['visitor_ids'])
+ self.assertEqual(partner_demo.visitor_ids, old_visitor, "The partner visitor should be back to the 'old' visitor.")
+
+ new_visitor = self.env['website.visitor'].search([('id', '=', new_visitor.id)])
+ self.assertEqual(len(new_visitor), 0, "The new visitor should be deleted when visitor authenticate once again.")
+ self.assertEqual(old_visitor.active, True, "The old visitor should be reactivated when visitor authenticates once again.")
--
cgit v1.2.3