From 198e226f5bd66dba6598d1ef85e5fa5fb82b38b2 Mon Sep 17 00:00:00 2001 From: Lucas Perais Date: Wed, 2 Aug 2023 15:40:37 +0200 Subject: [PATCH] [IMP] website: Expose models on the website This commit implements a way to expose any model publicly on the website. Both manual and existing models can create such pages called 'Model Pages'. A manual model is a model that has been created by the user at runtime, via the technical menu, via studio, or with an xml declaration. The main interface to easily create such page is in Studio, in the tab Website Integration. But a partner or developer could easily import those without studio installed. That's the reason most of the code to handle the display of such pages is present in the website module directly. The listing can handle two display modes (Grid or List). Once the user changes the display mode, the latest value is set in the session to be remembered for the next visit of the page. A default_layout can be set and is customizable in the website editor or from the backend of Website. This value is linked to the page to display, so each listing page can use a different layout by default. The route on which the models are exposed is: "/model/", With the derived routes: "/model//page/", "/model//" task-id-3231144 Part-of: odoo/odoo#130544 Co-authored-by: Florent Dardenne (dafl) Co-authored-by: Luca Vitali (luvi) --- addons/test_website/models/model.py | 12 ++ .../test_website/security/ir.model.access.csv | 1 + .../tests/tours/website_controller_page.js | 70 ++++++++ addons/test_website/tests/__init__.py | 1 + .../tests/test_website_controller_page.py | 164 ++++++++++++++++++ addons/website/__manifest__.py | 5 + addons/website/controllers/__init__.py | 1 + addons/website/controllers/main.py | 5 + addons/website/controllers/model_page.py | 134 ++++++++++++++ addons/website/models/__init__.py | 1 + addons/website/models/ir_ui_view.py | 1 + .../website/models/website_controller_page.py | 64 +++++++ addons/website/models/website_menu.py | 9 + addons/website/security/ir.model.access.csv | 4 +- addons/website/security/website_security.xml | 22 +++ .../website_controller_page_listing_layout.js | 64 +++++++ .../src/scss/website_controller_page.scss | 86 +++++++++ .../options.js | 72 ++++++++ ...website_controller_page_listing_layout.xml | 17 ++ .../views/website_controller_pages_views.xml | 83 +++++++++ addons/website/views/website_views.xml | 1 + 21 files changed, 816 insertions(+), 1 deletion(-) create mode 100644 addons/test_website/static/tests/tours/website_controller_page.js create mode 100644 addons/test_website/tests/test_website_controller_page.py create mode 100644 addons/website/controllers/model_page.py create mode 100644 addons/website/models/website_controller_page.py create mode 100644 addons/website/static/src/js/website_controller_page_listing_layout.js create mode 100644 addons/website/static/src/scss/website_controller_page.scss create mode 100644 addons/website/static/src/snippets/s_website_controller_page_listing_layout/options.js create mode 100644 addons/website/views/snippets/s_website_controller_page_listing_layout.xml create mode 100644 addons/website/views/website_controller_pages_views.xml 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 @@ +