[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:
co-authored by
Florent Dardenne
Luca Vitali
parent
8d18978b05
commit
198e226f5b
@@ -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
|
||||
|
||||
|
@@ -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,
|
||||
},
|
||||
]);
|
||||
@@ -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')
|
||||
@@ -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',
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
from . import backend
|
||||
from . import form
|
||||
from . import main
|
||||
from . import model_page
|
||||
from . import webclient
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
+72
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user