[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/<string:page_name_slugified>",

With the derived routes:
"/model/<string:page_name_slugified>/page/<int:page_number>",
"/model/<string:page_name_slugified>/<string:record_slug>"

task-id-3231144

Part-of: odoo/odoo#130544
Co-authored-by: Florent Dardenne (dafl) <dafl@odoo.com>
Co-authored-by: Luca Vitali (luvi) <luvi@odoo.com>
This commit is contained in:
Lucas Perais
2023-10-26 12:10:08 +00:00
co-authored by Florent Dardenne Luca Vitali
parent 8d18978b05
commit 198e226f5b
21 changed files with 816 additions and 1 deletions
+12
View File
@@ -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()
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_test_model_public access_test_model model_test_model base.group_public 1 0 0 0
3 access_test_model_portal access_test_model model_test_model base.group_portal 1 0 0 0
4 access_test_model_employee access_test_model model_test_model base.group_user 1 0 0 0
5 access_test_model_exposed_employee access_test_model_exposed model_test_model_exposed base.group_user 1 0 0 0
@@ -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,
},
]);
+1
View File
@@ -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
@@ -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": """<t t-call="website.layout">
<t t-set="_activeClasses">border-primary</t>
<div t-attf-class="listing_layout_switcher btn-group ms-3" t-att-data-active-classes="_activeClasses" t-att-data-view-id="view_id">
<input type="radio" class="btn-check" name="wstudio_layout" id="o_wstudio_apply_grid" value="grid" t-att-checked="'checked' if layout_mode != 'list' else None"/>
<label t-attf-class="btn btn-light #{_activeClasses if layout_mode != 'list' else None} o_wstudio_apply_grid" title="Grid" for="o_wstudio_apply_grid">
<i class="fa fa-th-large"/>
</label>
<input type="radio" class="btn-check" name="wstudio_layout" id="o_wstudio_apply_list" t-att-checked="'checked' if layout_mode == 'list' else None" value="list"/>
<label t-attf-class="btn btn-light #{_activeClasses if layout_mode == 'list' else None} o_wstudio_apply_list" title="List" for="o_wstudio_apply_list">
<i class="oi oi-view-list"/>
</label>
</div>
<div t-attf-class="row mx-n2 mt8 #{'o_website_grid' if layout_mode == 'grid' else 'o_website_list'}">
<t t-foreach="records" t-as="record">
<a class="test_record_listing" t-out="record.display_name" t-att-href="record_to_url(record)" />
</t>
</div>
</t> """
})
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": """<t t-call="website.layout">
<div class="test_record" t-out="record.display_name" />
</t> """
})
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')
+5
View File
@@ -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',
+1
View File
@@ -4,4 +4,5 @@
from . import backend
from . import form
from . import main
from . import model_page
from . import webclient
+5
View File
@@ -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(
+134
View File
@@ -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/<string:page_name_slugified>",
"/model/<string:page_name_slugified>/page/<int:page_number>",
"/model/<string:page_name_slugified>/<string:record_slug>",
], 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)
+1
View File
@@ -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
+1
View File
@@ -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='')
@@ -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/<string:page_name_slugified>
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()
+9
View File
@@ -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):
+3 -1
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
10 access_website_rewrite access_website_rewrite model_website_rewrite 0 0 0 0
11 access_website_rewrite_designer Web Rewrite Manager model_website_rewrite group_website_designer 1 1 1 1
12 access_website_page_designer Web Page Manager model_website_page group_website_designer 1 1 1 1
13 access_website_controller_page_designer Web Model Page Manager model_website_controller_page group_website_designer 1 1 1 1
14 access_website_controller_page_public Web Model Page Expose model_website_controller_page website_page_controller_expose 1 0 0 0
15 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
16 access_website_ir_ui_view_designer access_website_ir_ui_view_designer model_ir_ui_view group_website_designer 1 1 1 1
17 access_website_ir_asset_designer access_website_ir_asset_designer model_ir_asset group_website_designer 1 1 1 1
32 access_website_robots access.website.robots model_website_robots website.group_website_designer 1 1 1 0
33 access_website_dynamic_filter_public access_website_dynamic_filter model_website_snippet_filter 0 0 0 0
34 access_website_dynamic_filter_system access_website_dynamic_filter model_website_snippet_filter base.group_system 1 1 1 1
35 access_website_configurator_feature_designer access_website_configurator_feature_designer model_website_configurator_feature website.group_website_designer 1 0 1 0 1 0 1
@@ -90,4 +90,26 @@
</record>
</data>
<!-- Website Page Controller: exposing arbitrary models -->
<record id="website_page_controller_expose" model="res.groups">
<field name="name">Public access to arbitrary exposed model</field>
<field name="category_id" ref="base.module_category_website_website"/>
</record>
<record id="base.group_public" model="res.groups">
<field name="implied_ids" eval="[(4, ref('website.website_page_controller_expose'))]"/>
</record>
<record id="base.group_portal" model="res.groups">
<field name="implied_ids" eval="[(4, ref('website.website_page_controller_expose'))]"/>
</record>
<record id="website_page_controller_rule_public" model="ir.rule">
<field name="name">website.controller.page: portal/public: read published pages</field>
<field name="model_id" ref="website.model_website_controller_page"/>
<field name="domain_force">[('website_published', '=', True)]</field>
<field name="groups" eval="[(4, ref('website.website_page_controller_expose'))]"/>
</record>
</odoo>
@@ -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;
});
}
}
});
@@ -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;
}
}
@@ -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);
},
});
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="s_website_controller_page_listing_layout" name="Default layout" inherit_id="website.snippet_options">
<xpath expr="." position="inside">
<!-- All records page -->
<div data-js="WebsiteControllerPageListingLayout" data-page-options="true" groups="website.group_website_designer" data-selector=".listing_layout_switcher" data-no-check="true"
string="Layout">
<we-select data-name="default_listing_layout" string="Default layout" data-no-preview="true" data-reload="/">
<we-button data-set-layout="grid" data-name="grid_view_opt">Grid</we-button>
<we-button data-set-layout="list" data-name="list_view_opt">List</we-button>
</we-select>
</div>
</xpath>
</template>
</odoo>
@@ -0,0 +1,83 @@
<?xml version="1.0"?>
<odoo>
<record id="website_controller_pages_form_view" model="ir.ui.view">
<field name="name">website.controller.page.form</field>
<field name="model">website.controller.page</field>
<field name="arch" type="xml">
<form string="Website Model Page Settings">
<sheet>
<group col="2" string="Page Details">
<group colspan="1" string="Page">
<field name="website_id" options="{'no_create': True}" groups="website.group_multi_website"/>
<field name="page_name"/>
<label for="name_slugified" string="URL"/>
<div>
<span>/model/</span>
<field name="name_slugified" nolabel="1" class="d-inline bg-300" force_save="True"/>
<span invisible="name_slugified" class="bg-300">...</span>
<span invisible="page_type == 'listing'">
/my-little-record-23
</span>
</div>
<field name="website_published" />
</group>
<group colspan="1" string="Settings">
<field name="model" invisible="1"/>
<field name="record_domain" widget="domain" options="{'in_dialog': True, 'model': 'model'}"/>
<field name="page_type" required="1"/>
<field name="default_layout" invisible="page_type != 'listing'" />
</group>
</group>
<notebook groups="base.group_no_one">
<page string="View">
<group>
<field name="view_id" context="{'display_website': True}"/>
<field name="name"/>
</group>
</page>
<page string="Menus">
<label for="menu_ids" string="Related Menu Items"/>
<field name="menu_ids"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="website_controller_pages_tree_view" model="ir.ui.view">
<field name="name">website.controller.page.list</field>
<field name="model">website.controller.page</field>
<field name="priority">99</field>
<field name="arch" type="xml">
<tree>
<field name="name" string="Page Title"/>
<field name="name_slugified"/>
<field name="page_type"/>
<field name="view_id" column_invisible="True"/>
<!-- website_id should be shown only in multi website environment
when the group is enabled, but we need the field to be there all the
time for `PageRendererMixin`'s `recordFilter' to be able to filter
correctly. -->
<field name="website_id" column_invisible="True"/>
<field name="website_id" groups="website.group_multi_website"/>
</tree>
</field>
</record>
<record id="action_website_controller_pages_list" model="ir.actions.act_window">
<field name="name">Website Model Pages</field>
<field name="res_model">website.controller.page</field>
<field name="view_mode">tree,kanban,form</field>
</record>
<menuitem id="menu_website_controller_pages_list"
parent="menu_content"
sequence="10"
name="Model Pages"
groups="base.group_no_one"
action="action_website_controller_pages_list" />
</odoo>
+1
View File
@@ -101,6 +101,7 @@
<field name="name"/>
<field name="url"/>
<field name="page_id"/>
<field name="controller_page_id"/>
<field name="is_mega_menu"/>
</group>
<group>