diff --git a/addons/test_website/models/model.py b/addons/test_website/models/model.py index 33c19f386ec..3c7150f6448 100644 --- a/addons/test_website/models/model.py +++ b/addons/test_website/models/model.py @@ -31,3 +31,15 @@ class TestModel(models.Model): 'icon': 'fa-check-square-o', 'order': 'name asc, id desc', } + + +class TestModelExposed(models.Model): + _name = "test.model.exposed" + _inherit = [ + 'website.seo.metadata', + 'website.published.mixin', + ] + _description = 'Website Model Test Exposed' + _rec_name = "name" + + name = fields.Char() diff --git a/addons/test_website/security/ir.model.access.csv b/addons/test_website/security/ir.model.access.csv index 3607ad47f4a..ff919c8df8d 100644 --- a/addons/test_website/security/ir.model.access.csv +++ b/addons/test_website/security/ir.model.access.csv @@ -2,3 +2,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_test_model_public,access_test_model,model_test_model,base.group_public,1,0,0,0 access_test_model_portal,access_test_model,model_test_model,base.group_portal,1,0,0,0 access_test_model_employee,access_test_model,model_test_model,base.group_user,1,0,0,0 +access_test_model_exposed_employee,access_test_model_exposed,model_test_model_exposed,base.group_user,1,0,0,0 diff --git a/addons/test_website/static/tests/tours/website_controller_page.js b/addons/test_website/static/tests/tours/website_controller_page.js new file mode 100644 index 00000000000..9d31bf7bda2 --- /dev/null +++ b/addons/test_website/static/tests/tours/website_controller_page.js @@ -0,0 +1,70 @@ +/** @odoo-module **/ + +import wTourUtils from '@website/js/tours/tour_utils'; + +function assertEqual(actual, expected) { + if (actual !== expected) { + throw new Error(`Assert failed: expected: ${expected} ; got: ${actual}`); + } +} + +wTourUtils.registerWebsitePreviewTour('website_controller_page_listing_layout', { + test: true, + url: '/model/exposed-model', + edition: true, +}, () => [ + { + content: "website is in preview mode", + trigger: '.o_website_preview', + }, + { + content: "records are listed in grid mode by default", + trigger: 'iframe .o_website_grid', + run: () => { + const iframeDocument = document.querySelector('.o_website_preview .o_iframe').contentDocument; + // grid option is selected by default in the switch + assertEqual(iframeDocument.querySelector(".listing_layout_switcher #o_wstudio_apply_grid").checked, true); + assertEqual([...iframeDocument.querySelectorAll(".test_record_listing")].length, 2); + }, + }, + { + content: "open customize tab", + trigger: '.o_we_customize_snippet_btn', + }, + { + content: "open 'Layout' selector", + extra_trigger: '#oe_snippets .o_we_customize_panel', + trigger: '[data-name="default_listing_layout"] we-toggler', + }, + { + content: "click on 'List' option of the 'Layout' selector", + trigger: '.o_we_user_value_widget we-button[data-name="list_view_opt"]', + }, + { + content: "records are now displayed in list mode", + trigger: 'iframe .o_website_list', + run: () => { + const iframeDocument = document.querySelector('.o_website_preview .o_iframe').contentDocument; + // list option is now selected in the switch + assertEqual(iframeDocument.querySelector(".listing_layout_switcher #o_wstudio_apply_list").checked, true); + }, + }, + ...wTourUtils.clickOnSave(), +]); + +wTourUtils.registerWebsitePreviewTour('website_controller_page_default_page_check', { + test: true, + url: '/model/exposed-model', +}, () => [ + { + content: "records are listed in list mode by default", + trigger: 'iframe .o_website_list', + run: () => { + const iframeDocument = document.querySelector('.o_website_preview .o_iframe').contentDocument; + // list option is selected by default in the switch + assertEqual(iframeDocument.querySelector(".listing_layout_switcher #o_wstudio_apply_list").checked, true); + assertEqual([...iframeDocument.querySelectorAll(".test_record_listing")].length, 2); + }, + isCheck: true, + }, +]); diff --git a/addons/test_website/tests/__init__.py b/addons/test_website/tests/__init__.py index 9f5896c05be..977fcd95c7c 100644 --- a/addons/test_website/tests/__init__.py +++ b/addons/test_website/tests/__init__.py @@ -17,3 +17,4 @@ from . import test_reset_views from . import test_session from . import test_settings from . import test_views_during_module_operation +from . import test_website_controller_page diff --git a/addons/test_website/tests/test_website_controller_page.py b/addons/test_website/tests/test_website_controller_page.py new file mode 100644 index 00000000000..aa2c284e6a4 --- /dev/null +++ b/addons/test_website/tests/test_website_controller_page.py @@ -0,0 +1,164 @@ +from lxml import html +from odoo.addons.http_routing.models.ir_http import slug + +from odoo.tools import mute_logger +from odoo.exceptions import AccessError +from odoo.tests import HttpCase, tagged + +from odoo.addons.website.controllers.model_page import ModelPageController + +@tagged('post_install', '-at_install') +class TestWebsiteControllerPage(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.model = cls.env["ir.model"]._get("test.model.exposed") + + cls.model_acl = cls.env["ir.model.access"].create({ + "name": "test acl expose", + "model_id": cls.model.id, + "group_id": cls.env.ref("website.website_page_controller_expose").id, + "perm_read": True, + }) + + cls.listing_view = cls.env["ir.ui.view"].create({ + "type": "qweb", + "model": cls.model.model, + "arch": """ + border-primary +
+ + + + +
+ +
+ + + +
+
""" + }) + + cls.listing_controller_page = cls.env["website.controller.page"].create({ + "page_name": "Exposed Model", + "page_type": "listing", + "view_id": cls.listing_view.id, + "record_domain": "[('name', '=ilike', 'test_partner_%')]", + "website_published": True, + }) + + cls.single_view = cls.env["ir.ui.view"].create({ + "type": "qweb", + "model": cls.model.model, + "arch": """ +
+ """ + }) + + cls.single_controller_page = cls.env["website.controller.page"].create({ + "page_name": "Exposed Model", + "page_type": "single", + "website_id": False, + "view_id": cls.single_view.id, + "record_domain": "[('name', '=ilike', 'test_partner_%')]", + "website_published": True, + }) + + records_to_create = [{"name": f"test_partner_{i}"} for i in range(2)] + cls.exposed_records = cls.env[cls.model.model].create(records_to_create) + + def test_cannot_bypass_read_rights(self): + self.env["ir.model.access"].search([("model_id", "=", self.model.id)]).perm_read = False + + with self.assertRaises(AccessError) as cm: + self.env["website.controller.page"].with_user(2).create({ + "page_name": "Exposed Model", + "page_type": "single", + "website_id": False, + "view_id": self.single_view.id, + "record_domain": "[('name', '=ilike', 'test_partner_%')]", + "website_published": True, + }) + self.assertEqual(str(cm.exception).split("\n")[0], "You are not allowed to access 'Website Model Test Exposed' (test.model.exposed) records.") + + def test_access_rights_and_rules(self): + self.authenticate(None, None) + self.model_acl.active = False + with mute_logger("odoo.http"): + response = self.url_open(f"/model/{self.listing_controller_page.name_slugified}") + self.assertEqual(response.status_code, 403) + + self.model_acl.active = True + response = self.url_open(f"/model/{self.listing_controller_page.name_slugified}") + self.assertEqual(response.status_code, 200) + tree = html.fromstring(response.content.decode()) + rec_nodes = tree.xpath("//a[@class='test_record_listing']") + self.assertEqual(len(rec_nodes), 2) + + self.env["ir.rule"].create({ + "name": "dummy", + "model_id": self.model.id, + "domain_force": "[('name', '=', 'test_partner_1')]", + "groups": self.env.ref("base.group_public"), + }) + response = self.url_open(f"/model/{self.listing_controller_page.name_slugified}") + tree = html.fromstring(response.content.decode()) + rec_nodes = tree.xpath("//a[@class='test_record_listing']") + self.assertEqual(len(rec_nodes), 1) + + def test_expose_model(self): + self.authenticate(None, None) + + response = self.url_open(f"/model/{self.listing_controller_page.name_slugified}") + tree = html.fromstring(response.content.decode()) + rec_nodes = tree.xpath("//a[@class='test_record_listing']") + self.assertEqual(len(rec_nodes), 2) + for n, record in zip(rec_nodes, self.exposed_records): + self.assertEqual(n.get("href"), f"/model/{self.single_controller_page.name_slugified}/{slug(record)}") + + response = self.url_open(f"/model/{self.single_controller_page.name_slugified}/{slug(self.exposed_records[0])}") + tree = html.fromstring(response.content.decode()) + self.assertEqual(len(tree.xpath("//div[@class='test_record']")), 1) + + response = self.url_open(f"/model/{self.single_controller_page.name_slugified}/fake-slug-{self.exposed_records[0].id}") + self.assertEqual(response.status_code, 404) + + non_reachable_record = self.env[self.model.model].create({"name": "non_reachable"}) + response = self.url_open(f"/model/{self.single_controller_page.name_slugified}/{slug(non_reachable_record)}") + self.assertEqual(response.status_code, 404) + + response = self.url_open("/model/some-other-slug") + self.assertEqual(response.status_code, 404) + + self.listing_controller_page.website_published = False + response = self.url_open(f"/model/{self.listing_controller_page.name_slugified}") + self.assertEqual(response.status_code, 404) + + def test_search_listing(self): + self.authenticate(None, None) + + response = self.url_open(f"/model/{self.listing_controller_page.name_slugified}?search=1") + tree = html.fromstring(response.content.decode()) + rec_nodes = tree.xpath("//a[@class='test_record_listing']") + self.assertEqual(len(rec_nodes), 1) + self.assertEqual(rec_nodes[0].get("href"), f"/model/{self.single_controller_page.name_slugified}/{slug(self.exposed_records[1])}") + + self.patch(ModelPageController, "pager_step", 1) + response = self.url_open(f"/model/{self.listing_controller_page.name_slugified}/page/2") + tree = html.fromstring(response.content.decode()) + rec_nodes = tree.xpath("//a[@class='test_record_listing']") + self.assertEqual(len(rec_nodes), 1) + self.assertEqual(rec_nodes[0].get("href"), f"/model/{self.single_controller_page.name_slugified}/{slug(self.exposed_records[1])}") + + def test_default_layout(self): + self.assertEqual(self.listing_controller_page.default_layout, 'grid') + self.start_tour('/model/exposed-model', 'website_controller_page_listing_layout', login='admin') + self.assertEqual(self.listing_controller_page.default_layout, 'list') + #check that the user that has not previously interacted with the layout switcher will prompt on the default layout + self.start_tour('/model/exposed-model', 'website_controller_page_default_page_check', login='admin') diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 9e424c60fd0..5fa40d71f6b 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -93,6 +93,7 @@ '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', @@ -101,6 +102,7 @@ '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', @@ -125,6 +127,7 @@ ('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', @@ -137,6 +140,7 @@ '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', @@ -246,6 +250,7 @@ 'website/static/src/snippets/s_dynamic_snippet/options.js', 'website/static/src/snippets/s_dynamic_snippet_carousel/options.js', 'website/static/src/snippets/s_embed_code/options.js', + 'website/static/src/snippets/s_website_controller_page_listing_layout/options.js', 'website/static/src/snippets/s_website_form/options.js', 'website/static/src/js/form_editor_registry.js', 'website/static/src/js/send_mail_form.js', diff --git a/addons/website/controllers/__init__.py b/addons/website/controllers/__init__.py index 93f30efa6b6..5e6fd38f904 100644 --- a/addons/website/controllers/__init__.py +++ b/addons/website/controllers/__init__.py @@ -4,4 +4,5 @@ from . import backend from . import form from . import main +from . import model_page from . import webclient diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index 47eec868503..17222a547b0 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -380,6 +380,11 @@ class Website(Home): ] } + @http.route('/website/save_session_layout_mode', type='json', auth='public', website=True) + def save_session_layout_mode(self, layout_mode, view_id): + assert layout_mode in ('grid', 'list'), "Invalid layout mode" + request.session[f'website_{view_id}_layout_mode'] = layout_mode + @http.route('/website/snippet/filters', type='json', auth='public', website=True) def get_dynamic_filter(self, filter_id, template_key, limit=None, search_domain=None, with_sample=False): dynamic_filter = request.env['website.snippet.filter'].sudo().search( diff --git a/addons/website/controllers/model_page.py b/addons/website/controllers/model_page.py new file mode 100644 index 00000000000..e0b08c7d084 --- /dev/null +++ b/addons/website/controllers/model_page.py @@ -0,0 +1,134 @@ +import ast + +import werkzeug + +from odoo.addons.http_routing.models.ir_http import slug, unslug +from odoo.http import Controller, request, route +from odoo.osv.expression import AND, OR + + +class ModelPageController(Controller): + pager_step = 20 + + @route([ + "/model/", + "/model//page/", + "/model//", + ], website=True, auth="public") + def generic_model(self, page_name_slugified=None, page_number=1, record_slug=None, **searches): + if not page_name_slugified: + raise werkzeug.exceptions.NotFound() + + website = request.website + + page_type = "listing" + if record_slug is not None: + page_type = "single" + + website_page_domain = AND([ + [("page_type", "=", page_type)], + [("name_slugified", "=", page_name_slugified)], + [("website_published", "=", True)], + website.website_domain(), + ]) + + page = request.env["website.controller.page"].search(website_page_domain, limit=1) + if not page: + raise werkzeug.exceptions.NotFound() + + view = page.sudo().view_id + if not view: + raise werkzeug.exceptions.NotFound() + + target_model_name = page.sudo().model_id.model + Model = request.env[target_model_name] + if not Model.check_access_rights("read", raise_exception=False): + raise werkzeug.exceptions.Forbidden() + + rec_domain = ast.literal_eval(page.record_domain or "[]") + domains = [rec_domain] + + if record_slug: + _, res_id = unslug(record_slug) + record = Model.browse(res_id).filtered_domain(AND(domains)) + # We check for slug matching because we are not entirely sure + # that we end up seeing record for the right model + # i.e. in case of a redirect when a "single" page doesn't match the listing + if not record.exists() or record_slug != slug(record): + raise werkzeug.exceptions.NotFound() + + listing = request.env["website.controller.page"].search(AND([ + [("name_slugified", "=", page_name_slugified)], + [("page_type", "=", "listing")], + [("model", "=", target_model_name)], + website.website_domain(), + ])) + + render_context = { + "main_object": page.sudo(), # The template reads some fields that are actually on view + "record": record, + "listing": { + 'href': '.', + 'name': listing.page_name + } if listing else False + } + return request.render(view.key, render_context) + + layout_mode = request.session.get(f'website_{view.id}_layout_mode') + if not layout_mode: + # use the default layout set for this page + layout_mode = page.default_layout + + searches.setdefault("search", "") + searches.setdefault("order", "create_date desc") + + single_record_pages = request.env["website.controller.page"].search(AND([ + [("page_type", "=", "single")], + [("model", "=", target_model_name)], + website.website_domain(), + ])) + + single_record_page = single_record_pages.filtered(lambda rec: rec.name_slugified == page_name_slugified) + if single_record_page: + single_record_page = single_record_page[0] + else: + single_record_page = single_record_pages[:1] + + def record_to_url(record): + if not single_record_page: + return None + return "/model/%s/%s" % (single_record_page.name_slugified, slug(record)) + + if searches["search"]: + # _name_search doesn't take offset, we reimplement the logic that builds the name domain here + search_fnames = set(Model._rec_names_search or ([Model._rec_name] if Model._rec_name else [])) + if "seo_name" in Model._fields: + search_fnames.add("seo_name") + if search_fnames: + name_domain = OR([[(name_field, "ilike", searches["search"])] for name_field in search_fnames]) + domains.append(name_domain) + + search_count = Model.search_count(AND(domains)) + pager = website.pager( + url=f"/model/{page.name_slugified}", + url_args=searches, + total=search_count, + page=page_number, + step=self.pager_step, + scope=5, + ) + + records = Model.search(AND(domains), limit=self.pager_step, offset=self.pager_step * (page_number - 1), order=searches["order"]) + + render_context = { + "order_by": searches["order"], + "search": searches["search"], + "search_count": search_count, + "pager": pager, + "records": records, + "record_to_url": record_to_url, + "layout_mode": layout_mode, + "view_id": view.id, + "main_object": page.sudo(), # The template reads some fields that are actually on view + } + return request.render(view.key, render_context) diff --git a/addons/website/models/__init__.py b/addons/website/models/__init__.py index 68013552d6c..11025584a3a 100644 --- a/addons/website/models/__init__.py +++ b/addons/website/models/__init__.py @@ -31,3 +31,4 @@ from . import website_configurator_feature from . import website_form from . import website_snippet_filter from . import website_visitor +from . import website_controller_page diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index ed351b3cce2..5d983d995ad 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -23,6 +23,7 @@ class View(models.Model): website_id = fields.Many2one('website', ondelete='cascade', string="Website") page_ids = fields.One2many('website.page', 'view_id') + controller_page_ids = fields.One2many('website.controller.page', 'view_id') first_page_id = fields.Many2one('website.page', string='Website Page', help='First page linked to this view', compute='_compute_first_page_id') track = fields.Boolean(string='Track', default=False, help="Allow to specify for one page of the website to be trackable or not") visibility = fields.Selection([('', 'All'), ('connected', 'Signed In'), ('restricted_group', 'Restricted Group'), ('password', 'With Password')], default='') diff --git a/addons/website/models/website_controller_page.py b/addons/website/models/website_controller_page.py new file mode 100644 index 00000000000..531796a5ac7 --- /dev/null +++ b/addons/website/models/website_controller_page.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.http_routing.models.ir_http import slugify +from odoo import api, fields, models + + +class WebsiteControllerPage(models.Model): + _name = 'website.controller.page' + _inherits = {'ir.ui.view': 'view_id'} + _inherit = [ + 'website.published.multi.mixin', + 'website.searchable.mixin', + ] + _description = 'Model Page' + _order = 'website_id' + + view_id = fields.Many2one('ir.ui.view', string='View', required=True, ondelete="cascade") + menu_ids = fields.One2many('website.menu', 'controller_page_id', 'Related Menus') + + website_id = fields.Many2one(related='view_id.website_id', store=True, readonly=False, ondelete='cascade') + + # Bindings to model/records, to expose the page on the website. + # Route: /model/ + page_name = fields.Char(string="Name", help="The name is used to generate the URL and is shown in the browser title bar", required=True) + name_slugified = fields.Char(compute="_compute_name_slugified", store=True, + string="URL", help="The name of the page usable in a URL") + page_type = fields.Selection(selection=[("listing", "Listing"), ("single", "Single record")], + default="listing", string="Page Type", + help="The type of the page. If set, it indicates whether the page displays a list of records or a single record") + record_domain = fields.Char(string="Domain", help="Domain to restrict records that can be viewed publicly") + default_layout = fields.Selection( + selection=[ + ('grid', "Grid"), + ('list', "List"), + ], + default="grid", + ) + + @api.constrains('view_id', 'model_id', "model") + def _check_user_has_model_access(self): + for record in self: + self.env[record.model_id.model].check_access_rights('read') + + @api.depends("model_id", "page_name") + def _compute_name_slugified(self): + for rec in self: + if not rec.model_id or not rec.page_type: + continue + rec.name_slugified = slugify(rec.page_name or '') + + def unlink(self): + # When a website_controller_page is deleted, the ORM does not delete its + # ir_ui_view. So we got to delete it ourself, but only if the + # ir_ui_view is not used by another website_page. + views_to_delete = self.view_id.filtered( + lambda v: v.controller_page_ids <= self and not v.inherit_children_ids + ) + # Rebind self to avoid unlink already deleted records from `ondelete="cascade"` + self = self - views_to_delete.controller_page_ids + views_to_delete.unlink() + + # Make sure website._get_menu_ids() will be recomputed + self.env.registry.clear_cache() + return super().unlink() diff --git a/addons/website/models/website_menu.py b/addons/website/models/website_menu.py index 45aeaa032d0..3a5baeb7907 100644 --- a/addons/website/models/website_menu.py +++ b/addons/website/models/website_menu.py @@ -41,6 +41,7 @@ class Menu(models.Model): name = fields.Char('Menu', required=True, translate=True) url = fields.Char('Url', default='') page_id = fields.Many2one('website.page', 'Related Page', ondelete='cascade') + controller_page_id = fields.Many2one('website.controller.page', 'Related Model Page', ondelete='cascade') new_window = fields.Boolean('New Window') sequence = fields.Integer(default=_default_sequence) website_id = fields.Many2one('website', 'Website', ondelete='cascade') @@ -125,6 +126,14 @@ class Menu(models.Model): or (not page_sudo.view_id._handle_visibility(do_raise=False) and page_sudo.view_id._get_cached_visibility() != "password")): visible = False + + if menu.controller_page_id and not menu.user_has_groups('base.group_user'): + controller_page_sudo = menu.controller_page_id.sudo() + if (not controller_page_sudo.is_published + or (not controller_page_sudo.view_id._handle_visibility(do_raise=False) + and controller_page_sudo.view_id._get_cached_visibility() != "password")): + visible = False + menu.is_visible = visible def _clean_url(self): diff --git a/addons/website/security/ir.model.access.csv b/addons/website/security/ir.model.access.csv index 9f4b9b97db5..99bdaf722d8 100644 --- a/addons/website/security/ir.model.access.csv +++ b/addons/website/security/ir.model.access.csv @@ -10,6 +10,8 @@ access_website_menu_designer,Web Menu Manager,model_website_menu,group_website_d access_website_rewrite,access_website_rewrite,model_website_rewrite,,0,0,0,0 access_website_rewrite_designer,Web Rewrite Manager,model_website_rewrite,group_website_designer,1,1,1,1 access_website_page_designer,Web Page Manager,model_website_page,group_website_designer,1,1,1,1 +access_website_controller_page_designer,Web Model Page Manager,model_website_controller_page,group_website_designer,1,1,1,1 +access_website_controller_page_public,Web Model Page Expose,model_website_controller_page,website_page_controller_expose,1,0,0,0 access_website_ir_ui_view_restricted_editor,access_website_ir_ui_view_restricted_editor,model_ir_ui_view,group_website_restricted_editor,1,0,0,0 access_website_ir_ui_view_designer,access_website_ir_ui_view_designer,model_ir_ui_view,group_website_designer,1,1,1,1 access_website_ir_asset_designer,access_website_ir_asset_designer,model_ir_asset,group_website_designer,1,1,1,1 @@ -30,4 +32,4 @@ access_theme_website_page,access_theme_website_page,model_theme_website_page,bas access_website_robots,access.website.robots,model_website_robots,website.group_website_designer,1,1,1,0 access_website_dynamic_filter_public,access_website_dynamic_filter,model_website_snippet_filter,,0,0,0,0 access_website_dynamic_filter_system,access_website_dynamic_filter,model_website_snippet_filter,base.group_system,1,1,1,1 -access_website_configurator_feature_designer,access_website_configurator_feature_designer,model_website_configurator_feature,website.group_website_designer,1,0,0,0 +access_website_configurator_feature_designer,access_website_configurator_feature_designer,model_website_configurator_feature,website.group_website_designer,1,1,1,1 diff --git a/addons/website/security/website_security.xml b/addons/website/security/website_security.xml index cd6a07bd326..10082660630 100644 --- a/addons/website/security/website_security.xml +++ b/addons/website/security/website_security.xml @@ -90,4 +90,26 @@ + + + + Public access to arbitrary exposed model + + + + + + + + + + + + + website.controller.page: portal/public: read published pages + + [('website_published', '=', True)] + + + diff --git a/addons/website/static/src/js/website_controller_page_listing_layout.js b/addons/website/static/src/js/website_controller_page_listing_layout.js new file mode 100644 index 00000000000..0c1edfa4a4b --- /dev/null +++ b/addons/website/static/src/js/website_controller_page_listing_layout.js @@ -0,0 +1,64 @@ +/** @odoo-module **/ + +import publicWidget from "@web/legacy/js/public/public_widget"; + +publicWidget.registry.WebsiteControllerPageListingLayout = publicWidget.Widget.extend({ + selector: ".o_website_listing_layout", + disabledInEditableMode: true, + events: { + "change .listing_layout_switcher input": "_onApplyLayoutChange", + }, + init(parent, options) { + this._super(parent, options); + this.rpc = this.bindService("rpc"); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onApplyLayoutChange(ev) { + const wysiwyg = this.options.wysiwyg; + if (wysiwyg) { + wysiwyg.odooEditor.observerUnactive("_onApplyLayoutChange"); + } + const clickedValue = ev.target.value; + const isList = clickedValue === "list"; + if (!this.editableMode) { + this.rpc("/website/save_session_layout_mode", { + layout_mode: isList ? "list" : "grid", + view_id: document + .querySelector(".listing_layout_switcher") + .getAttribute("data-view-id"), + }); + } + + const activeClasses = ev.target.parentElement.dataset.activeClasses.split(" "); + ev.target.parentElement.querySelectorAll(".btn").forEach((btn) => { + activeClasses.map((c) => btn.classList.toggle(c)); + }); + + const el = document.querySelector(isList ? ".o_website_grid" : ".o_website_list"); + this._toggle_view_mode(el, isList); + + if (wysiwyg) { + wysiwyg.odooEditor.observerActive("_onApplyLayoutChange"); + } + }, + + _toggle_view_mode(el, isList) { + if (el) { + el.classList.toggle("o_website_list", isList); + el.classList.toggle("o_website_grid", !isList); + const classList = isList ? "" : "col-lg-3 col-md-4 col-sm-6 px-2 col-xs-12"; + // each card must have the correct bootstrap classes + [...document.querySelectorAll(".o_website_list > div, .o_website_grid > div")].forEach((card) => { + card.classList = classList; + }); + } + } +}); diff --git a/addons/website/static/src/scss/website_controller_page.scss b/addons/website/static/src/scss/website_controller_page.scss new file mode 100644 index 00000000000..0194af64a8b --- /dev/null +++ b/addons/website/static/src/scss/website_controller_page.scss @@ -0,0 +1,86 @@ +.o_website_tag { + vertical-align: middle; + @for $size from 1 through length($o-colors) { + &.o_tag_color_#{$size - 1} { + $background-color: white; + // no color selected + @if $size == 1 { + & { + color: black; + background-color: $background-color; + box-shadow: inset 0 0 0 1px nth($o-colors, $size); + } + } @else { + $background-color: nth($o-colors, $size); + & { + color: white; + background-color: $background-color; + } + } + @at-root a#{&} { + &:hover { + color: color-contrast($background-color); + background-color: darken($background-color, 10%); + } + } + } + } +} + +.o_website_record { + transition: box-shadow ease-in 0.1s; + + &:hover { + box-shadow: 0 5px 25px -10px black; + } + + .o_website_image { + object-fit: cover; + } +} + +.o_website_grid { + .o_website_record { + grid-template-rows: auto 1.5em 8em; + + .o_website_tags { + grid-row: 2; + } + + .card-body { + grid-row: 3; + } + + .o_website_image { + height: 12em !important; + } + } +} + +.o_website_list { + .o_website_record { + grid-template-columns: 10% 65% 25%; + height: 6em; + + .o_website_tags { + grid-column: 3; + grid-row: 1; + } + + .card-body { + grid-column: 2; + grid-row: 1; + } + } +} + +.o_website_record_page { + .o_website_html { + padding: 0 3vw; + } + + .o_website_image { + max-height: 90vh; + object-fit: contain; + } +} diff --git a/addons/website/static/src/snippets/s_website_controller_page_listing_layout/options.js b/addons/website/static/src/snippets/s_website_controller_page_listing_layout/options.js new file mode 100644 index 00000000000..45b8e75bd0c --- /dev/null +++ b/addons/website/static/src/snippets/s_website_controller_page_listing_layout/options.js @@ -0,0 +1,72 @@ +/** @odoo-module **/ + +import options from "@web_editor/js/editor/snippets.options"; + +const mainObjectRe = /website\.controller\.page\(((\d+,?)*)\)/; + +options.registry.WebsiteControllerPageListingLayout = options.Class.extend({ + init() { + this._super(...arguments); + this.rpc = this.bindService("rpc"); + this.orm = this.bindService("orm"); + this.resModel = "website.controller.page"; + }, + + /** + * @override + */ + async willStart() { + const _super = this._super.bind(this); + const mainObjectRepr = this.$target[0].ownerDocument.documentElement.getAttribute("data-main-object"); + const match = mainObjectRe.exec(mainObjectRepr); + if (match && match[1]) { + this.resIds = match[1].split(",").flatMap(e => { + if (!e) { + return []; + } + const id = parseInt(e); + return id ? [id] : []; + }); + } + + const results = await this.orm.read(this.resModel, this.resIds, ["default_layout"]); + this.layout = results[0]["default_layout"]; + return _super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + async setLayout(previewMode, widgetValue) { + const params = { + layout_mode: widgetValue, + view_id: this.$target[0].getAttribute("data-view-id"), + }; + // save the default layout display, and set the layout for the current user + await Promise.all([ + this.orm.write(this.resModel, this.resIds, { default_layout: widgetValue }), + this.rpc("/website/save_session_layout_mode", params), + ]); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * + * @param methodName + * @param params + * @returns {string|string|*} + * @private + */ + _computeWidgetState(methodName) { + switch (methodName) { + case 'setLayout': { + return this.layout; + } + } + return this._super(...arguments); + }, +}); diff --git a/addons/website/views/snippets/s_website_controller_page_listing_layout.xml b/addons/website/views/snippets/s_website_controller_page_listing_layout.xml new file mode 100644 index 00000000000..94a51705714 --- /dev/null +++ b/addons/website/views/snippets/s_website_controller_page_listing_layout.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/addons/website/views/website_controller_pages_views.xml b/addons/website/views/website_controller_pages_views.xml new file mode 100644 index 00000000000..ccc9007d436 --- /dev/null +++ b/addons/website/views/website_controller_pages_views.xml @@ -0,0 +1,83 @@ + + + + + website.controller.page.form + website.controller.page + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + website.controller.page.list + website.controller.page + 99 + + + + + + + + + + + + + + + + Website Model Pages + website.controller.page + tree,kanban,form + + + + +
diff --git a/addons/website/views/website_views.xml b/addons/website/views/website_views.xml index 6f7e797e337..553f243cd7d 100644 --- a/addons/website/views/website_views.xml +++ b/addons/website/views/website_views.xml @@ -101,6 +101,7 @@ +