[IMP] all/website: using lazy values and t-cache in templates
Using lazy values allows you to not do query when the content is not displayed or if it is already cached. Adding `t-cache` only reduces render time, but combining it with lazy values saves browses, computes and query. Thus for the `/shop` page the page is displayed in 60ms (before: 250ms). Part-of: odoo/odoo#88276
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
from odoo.tools import is_html_empty
|
||||
from odoo.tools import is_html_empty, lazy
|
||||
|
||||
|
||||
class IrQWeb(models.AbstractModel):
|
||||
@@ -15,9 +15,9 @@ class IrQWeb(models.AbstractModel):
|
||||
irQweb = super()._prepare_frontend_environment(values)
|
||||
values.update(
|
||||
is_html_empty=is_html_empty,
|
||||
languages=[lang for
|
||||
languages=lazy(lambda: [lang for
|
||||
lang in irQweb.env['res.lang'].get_available()
|
||||
if lang[0] in irQweb.env['ir.http']._get_frontend_langs()]
|
||||
if lang[0] in irQweb.env['ir.http']._get_frontend_langs()])
|
||||
)
|
||||
for key in irQweb.env.context:
|
||||
if key not in values:
|
||||
|
||||
@@ -6,25 +6,27 @@
|
||||
<attribute name="t-attf-class" add="#{'o_portal' if is_portal else ''}" separator=" "/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='wrapwrap']/header/img" position="replace">
|
||||
<nav class="navbar navbar-expand navbar-light bg-light">
|
||||
<div class="container">
|
||||
<a href="/" class="navbar-brand logo">
|
||||
<img t-att-src="'/logo.png?company=%s' % res_company.id" t-att-alt="'Logo of %s' % res_company.name" t-att-title="res_company.name"/>
|
||||
</a>
|
||||
<ul id="top_menu" class="nav navbar-nav ml-auto">
|
||||
<t t-call="portal.placeholder_user_sign_in">
|
||||
<t t-set="_item_class" t-value="'nav-item'"/>
|
||||
<t t-set="_link_class" t-value="'nav-link'"/>
|
||||
</t>
|
||||
<t t-call="portal.user_dropdown">
|
||||
<t t-set="_user_name" t-value="true"/>
|
||||
<t t-set="_item_class" t-value="'nav-item dropdown'"/>
|
||||
<t t-set="_link_class" t-value="'nav-link'"/>
|
||||
<t t-set="_dropdown_menu_class" t-value="'dropdown-menu-right'"/>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<t t-cache="res_company">
|
||||
<nav class="navbar navbar-expand navbar-light bg-light">
|
||||
<div class="container">
|
||||
<a href="/" class="navbar-brand logo">
|
||||
<img t-att-src="'/logo.png?company=%s' % res_company.id" t-att-alt="'Logo of %s' % res_company.name" t-att-title="res_company.name"/>
|
||||
</a>
|
||||
<ul id="top_menu" class="nav navbar-nav ml-auto">
|
||||
<t t-call="portal.placeholder_user_sign_in">
|
||||
<t t-set="_item_class" t-value="'nav-item'"/>
|
||||
<t t-set="_link_class" t-value="'nav-link'"/>
|
||||
</t>
|
||||
<t t-call="portal.user_dropdown">
|
||||
<t t-set="_user_name" t-value="true"/>
|
||||
<t t-set="_item_class" t-value="'nav-item dropdown'"/>
|
||||
<t t-set="_link_class" t-value="'nav-link'"/>
|
||||
<t t-set="_dropdown_menu_class" t-value="'dropdown-menu-right'"/>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</t>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='wrapwrap']/main/t[@t-out='0']" position="before">
|
||||
<div t-if="o_portal_fullwidth_alert" class="alert alert-info alert-dismissible rounded-0 fade show d-print-none css_editable_mode_hidden">
|
||||
@@ -45,42 +47,57 @@
|
||||
</template>
|
||||
|
||||
<template id="language_selector" name="Language Selector">
|
||||
<t t-set="active_lang" t-value="list(filter(lambda lg : lg[0] == lang, languages))[0]"/>
|
||||
<t t-set="language_selector_visible" t-value="len(languages) > 1"/>
|
||||
<div t-attf-class="js_language_selector #{_div_classes} d-print-none" t-if="language_selector_visible">
|
||||
<button t-attf-class="btn btn-sm btn-outline-secondary border-0 dropdown-toggle #{_btn_class}" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
|
||||
<span t-if="not no_text"
|
||||
class="align-middle"
|
||||
t-esc="active_lang[2].split('/').pop()"/>
|
||||
</button>
|
||||
<div t-attf-class="dropdown-menu #{_dropdown_menu_class}" role="menu">
|
||||
<t t-foreach="languages" t-as="lg">
|
||||
<a t-att-href="url_for(request.httprequest.path + '?' + keep_query(), lang_code=lg[0])"
|
||||
t-attf-class="dropdown-item js_change_lang #{active_lang == lg and 'active'}"
|
||||
t-att-data-url_code="lg[1]">
|
||||
<span t-if="not no_text" t-esc="lg[2].split('/').pop()"/>
|
||||
</a>
|
||||
</t>
|
||||
<t t-nocache="The query strings can change for the same page and the same rendering."
|
||||
t-nocache-no_text="no_text"
|
||||
t-nocache-_div_classes="_div_classes"
|
||||
t-nocache-_btn_class="_btn_class"
|
||||
t-nocache-_dropdown_menu_class="_dropdown_menu_class">
|
||||
<t t-set="active_lang" t-value="list(filter(lambda lg : lg[0] == lang, languages))[0]"/>
|
||||
<t t-set="language_selector_visible" t-value="len(languages) > 1"/>
|
||||
<div t-attf-class="js_language_selector #{_div_classes} d-print-none" t-if="language_selector_visible">
|
||||
<button t-attf-class="btn btn-sm btn-outline-secondary border-0 dropdown-toggle #{_btn_class}" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
|
||||
<span t-if="not no_text"
|
||||
class="align-middle"
|
||||
t-esc="active_lang[2].split('/').pop()"/>
|
||||
</button>
|
||||
<div t-attf-class="dropdown-menu #{_dropdown_menu_class}" role="menu">
|
||||
<t t-foreach="languages" t-as="lg">
|
||||
<a t-att-href="url_for(request.httprequest.path + '?' + keep_query(), lang_code=lg[0])"
|
||||
t-attf-class="dropdown-item js_change_lang #{active_lang == lg and 'active'}"
|
||||
t-att-data-url_code="lg[1]">
|
||||
<span t-if="not no_text" t-esc="lg[2].split('/').pop()"/>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="user_dropdown" name="Portal User Dropdown">
|
||||
<t t-set="is_connected" t-value="not user_id._is_public()"/>
|
||||
<li t-if="is_connected" t-attf-class="#{_item_class} o_no_autohide_item">
|
||||
<a href="#" role="button" data-toggle="dropdown" t-attf-class="dropdown-toggle #{_link_class}">
|
||||
<t t-if="_avatar">
|
||||
<t t-set="avatar_source" t-value="image_data_uri(user_id.avatar_256)"/>
|
||||
<img t-att-src="avatar_source" t-attf-class="rounded-circle o_object_fit_cover #{_avatar_class}" width="24" height="24" alt="" loading="eager"/>
|
||||
</t>
|
||||
<i t-if="_icon" t-attf-class="fa fa-1x fa-fw fa-user-circle-o #{_icon_class}"/>
|
||||
<span t-if="_user_name" t-attf-class="#{_user_name_class}" t-esc="user_id.name[:23] + '...' if user_id.name and len(user_id.name) > 25 else user_id.name"/>
|
||||
</a>
|
||||
<div t-attf-class="dropdown-menu js_usermenu #{_dropdown_menu_class}" role="menu">
|
||||
<div id="o_logout_divider" class="dropdown-divider"/>
|
||||
<a t-attf-href="/web/session/logout?redirect=/" role="menuitem" id="o_logout" class="dropdown-item">Logout</a>
|
||||
</div>
|
||||
</li>
|
||||
<t t-nocache="Each user is different regardless of the page visited."
|
||||
t-nocache-_icon="_icon"
|
||||
t-nocache-_icon_class="_icon_class"
|
||||
t-nocache-_user_name="_user_name"
|
||||
t-nocache-_user_name_class="_user_name_class"
|
||||
t-nocache-_item_class="_item_class"
|
||||
t-nocache-_link_class="_link_class"
|
||||
t-nocache-_dropdown_menu_class="_dropdown_menu_class">
|
||||
<t t-set="is_connected" t-value="not user_id._is_public()"/>
|
||||
<li t-if="is_connected" t-attf-class="#{_item_class} o_no_autohide_item">
|
||||
<a href="#" role="button" data-toggle="dropdown" t-attf-class="dropdown-toggle #{_link_class}">
|
||||
<t t-if="_avatar">
|
||||
<t t-set="avatar_source" t-value="image_data_uri(user_id.avatar_256)"/>
|
||||
<img t-att-src="avatar_source" t-attf-class="rounded-circle o_object_fit_cover #{_avatar_class}" width="24" height="24" alt="" loading="eager"/>
|
||||
</t>
|
||||
<i t-if="_icon" t-attf-class="fa fa-1x fa-fw fa-user-circle-o #{_icon_class}"/>
|
||||
<span t-if="_user_name" t-attf-class="#{_user_name_class}" t-esc="user_id.name[:23] + '...' if user_id.name and len(user_id.name) > 25 else user_id.name"/>
|
||||
</a>
|
||||
<div t-attf-class="dropdown-menu js_usermenu #{_dropdown_menu_class}" role="menu">
|
||||
<div id="o_logout_divider" class="dropdown-divider"/>
|
||||
<a t-attf-href="/web/session/logout?redirect=/" role="menuitem" id="o_logout" class="dropdown-item">Logout</a>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="portal_breadcrumbs" name="Portal Breadcrumbs">
|
||||
@@ -149,7 +166,10 @@
|
||||
|
||||
<template id="user_sign_in" name="User Sign In" inherit_id="portal.placeholder_user_sign_in">
|
||||
<xpath expr="." position="inside">
|
||||
<li groups="base.group_public" t-attf-class="#{_item_class} o_no_autohide_item">
|
||||
<li t-nocache="Profile session and user group can change unrelated to parent caches."
|
||||
t-nocache-_item_class="_item_class"
|
||||
t-nocache-_link_class="_link_class"
|
||||
groups="base.group_public" t-attf-class="#{_item_class} o_no_autohide_item">
|
||||
<a t-attf-href="/web/login" t-attf-class="#{_link_class}">Sign in<span t-if="request.session.profile_session" class="text-danger fa fa-circle"/></a>
|
||||
</li>
|
||||
</xpath>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<t t-call-assets="web.assets_frontend" t-js="false"/>
|
||||
</xpath>
|
||||
<xpath expr="//head/script[@id='web.layout.odooscript']" position="after">
|
||||
<script type="text/javascript">
|
||||
<script t-nocache="Session information should always be up to date." type="text/javascript">
|
||||
odoo.__session_info__ = <t t-out="json.dumps(request.env['ir.http'].get_frontend_session_info())"/>;
|
||||
if (!/(^|;\s)tz=/.test(document.cookie)) {
|
||||
const userTZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
@@ -60,7 +60,7 @@
|
||||
<main>
|
||||
<t t-out="0"/>
|
||||
</main>
|
||||
<footer t-if="not no_footer" id="bottom" data-anchor="true" t-attf-class="bg-light o_footer">
|
||||
<footer t-cache="no_footer,no_copyright" t-if="not no_footer" id="bottom" data-anchor="true" t-attf-class="bg-light o_footer">
|
||||
<div id="footer"/>
|
||||
<div t-if="not no_copyright" class="o_footer_copyright">
|
||||
<div class="container py-3">
|
||||
|
||||
@@ -5,6 +5,7 @@ from collections import OrderedDict
|
||||
|
||||
from odoo import models
|
||||
from odoo.http import request
|
||||
from odoo.tools import lazy
|
||||
from odoo.addons.base.models.assetsbundle import AssetsBundle
|
||||
from odoo.addons.http_routing.models.ir_http import url_for
|
||||
from odoo.osv import expression
|
||||
@@ -60,31 +61,32 @@ class IrQWeb(models.AbstractModel):
|
||||
Website = irQweb.env['website']
|
||||
editable = request.website.is_publisher()
|
||||
translatable = editable and irQweb.env.context.get('lang') != irQweb.env['ir.http']._get_default_lang().code
|
||||
editable = not translatable and editable
|
||||
editable = editable and not translatable
|
||||
|
||||
current_website = Website.get_current_website()
|
||||
|
||||
has_group_publisher = irQweb.env.user.has_group('website.group_website_publisher')
|
||||
if has_group_publisher and irQweb.env.user.has_group('website.group_multi_website'):
|
||||
values['multi_website_websites_current'] = current_website.name
|
||||
values['multi_website_websites'] = [
|
||||
values['multi_website_websites_current'] = lazy(lambda: current_website.name)
|
||||
values['multi_website_websites'] = lazy(lambda: [
|
||||
{'website_id': website.id, 'name': website.name, 'domain': website.domain}
|
||||
for website in Website.search([('id', '!=', current_website.id)])
|
||||
]
|
||||
])
|
||||
|
||||
cur_company = irQweb.env.company
|
||||
values['multi_website_companies_current'] = {'company_id': cur_company.id, 'name': cur_company.name}
|
||||
values['multi_website_companies'] = [
|
||||
values['multi_website_companies_current'] = lazy(lambda: {'company_id': cur_company.id, 'name': cur_company.name})
|
||||
values['multi_website_companies'] = lazy(lambda: [
|
||||
{'company_id': comp.id, 'name': comp.name}
|
||||
for comp in irQweb.env.user.company_ids if comp != cur_company
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
# update values
|
||||
|
||||
values.update(dict(
|
||||
website=current_website,
|
||||
is_view_active=current_website.is_view_active,
|
||||
res_company=request.env['res.company'].browse(current_website._get_cached('company_id')).sudo(),
|
||||
is_view_active=lazy(lambda: current_website.is_view_active),
|
||||
res_company=lazy(request.env['res.company'].browse(current_website._get_cached('company_id')).sudo),
|
||||
translatable=translatable,
|
||||
editable=editable,
|
||||
))
|
||||
@@ -93,7 +95,7 @@ class IrQWeb(models.AbstractModel):
|
||||
# form editable object, add the backend configuration link
|
||||
if 'main_object' in values and has_group_publisher:
|
||||
func = getattr(values['main_object'], 'get_backend_menu_id', False)
|
||||
values['backend_menu_id'] = func and func() or irQweb.env['ir.model.data']._xmlid_to_res_id('website.menu_website_configuration')
|
||||
values['backend_menu_id'] = lazy(lambda: func and func() or irQweb.env['ir.model.data']._xmlid_to_res_id('website.menu_website_configuration'))
|
||||
|
||||
# update options
|
||||
|
||||
|
||||
@@ -361,8 +361,9 @@ class View(models.Model):
|
||||
Archived views are ignored (unless the active_test context is set, but
|
||||
then the ormcache_context will not work as expected).
|
||||
"""
|
||||
if 'website_id' in self._context and not isinstance(xml_id, int):
|
||||
current_website = self.env['website'].browse(self._context.get('website_id'))
|
||||
website_id = self._context.get('website_id')
|
||||
if website_id and not isinstance(xml_id, int):
|
||||
current_website = self.env['website'].browse(int(website_id))
|
||||
domain = ['&', ('key', '=', xml_id)] + current_website.website_domain()
|
||||
|
||||
view = self.sudo().search(domain, order='website_id', limit=1)
|
||||
@@ -372,6 +373,10 @@ class View(models.Model):
|
||||
return view.id
|
||||
return super(View, self.sudo())._get_view_id(xml_id)
|
||||
|
||||
@tools.ormcache('self.id')
|
||||
def _get_cached_visibility(self):
|
||||
return self.visibility
|
||||
|
||||
def _handle_visibility(self, do_raise=True):
|
||||
""" Check the visibility set on the main view and raise 403 if you should not have access.
|
||||
Order is: Public, Connected, Has group, Password
|
||||
@@ -382,10 +387,12 @@ class View(models.Model):
|
||||
|
||||
self = self.sudo()
|
||||
|
||||
if self.visibility and not request.env.user.has_group('website.group_website_designer'):
|
||||
if (self.visibility == 'connected' and request.website.is_public_user()):
|
||||
visibility = self._get_cached_visibility()
|
||||
|
||||
if visibility and not request.env.user.has_group('website.group_website_designer'):
|
||||
if (visibility == 'connected' and request.website.is_public_user()):
|
||||
error = werkzeug.exceptions.Forbidden()
|
||||
elif self.visibility == 'password' and \
|
||||
elif visibility == 'password' and \
|
||||
(request.website.is_public_user() or self.id not in request.session.get('views_unlock', [])):
|
||||
pwd = request.params.get('visibility_password')
|
||||
if pwd and self.env.user._crypt_context().verify(
|
||||
@@ -394,7 +401,7 @@ class View(models.Model):
|
||||
else:
|
||||
error = werkzeug.exceptions.Forbidden('website_visibility_password_required')
|
||||
|
||||
if self.visibility not in ('password', 'connected'):
|
||||
if visibility not in ('password', 'connected'):
|
||||
try:
|
||||
self._check_view_access()
|
||||
except AccessError:
|
||||
|
||||
@@ -119,7 +119,7 @@ class Menu(models.Model):
|
||||
if menu.page_id and not menu.user_has_groups('base.group_user') and \
|
||||
(not menu.page_id.sudo().is_visible or
|
||||
(not menu.page_id.view_id._handle_visibility(do_raise=False) and
|
||||
menu.page_id.view_id.visibility != "password")):
|
||||
menu.page_id.view_id._get_cached_visibility() != "password")):
|
||||
visible = False
|
||||
menu.is_visible = visible
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ class TestWebsitePerformance(UtilPerf):
|
||||
|
||||
def test_20_perf_sql_queries_homepage(self):
|
||||
# homepage "/" has its own controller
|
||||
self.assertEqual(self._get_url_hot_query('/'), 13)
|
||||
self.assertEqual(self._get_url_hot_query('/'), 14)
|
||||
self.assertEqual(self._get_url_hot_query('/', cache=False), 18)
|
||||
|
||||
def test_30_perf_sql_queries_page_no_layout(self):
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<nav groups="base.group_user" t-if="website" id="oe_main_menu_navbar" class="o_main_navbar d-flex align-items-center">
|
||||
<div id="oe_applications">
|
||||
<div t-cache="website" id="oe_applications">
|
||||
<div class="dropdown active pl-3">
|
||||
<a class="dropdown-toggle full" data-toggle="dropdown" href="#" accesskey="h">
|
||||
<i class="fa fa-th-large mr-2"/> WEBSITE
|
||||
@@ -70,7 +70,7 @@
|
||||
<a role="menuitem" data-action="promote-current-page" href="#" title="Promote page on the web" class="dropdown-item">Optimize SEO</a>
|
||||
</div>
|
||||
</div>
|
||||
<t t-call="website.website_publisher">
|
||||
<t t-call="website.website_publisher" groups="website.group_website_publisher">
|
||||
<t t-set="extra_classes" t-translation="off">d-flex d-sm-flex d-md-none</t>
|
||||
</t>
|
||||
</div>
|
||||
@@ -122,9 +122,8 @@
|
||||
<p>Page</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<t t-set="mod" t-value="env.ref('base.module_website_blog')"/>
|
||||
<div name="module_website_blog" t-if='is_system'
|
||||
<div name="module_website_blog" t-if='is_system' t-cache="is_system"
|
||||
t-att-data-module-id="mod.id"
|
||||
t-att-data-module-shortdesc="mod.shortdesc"
|
||||
class="col-md-4 mb8 o_new_content_element">
|
||||
@@ -134,7 +133,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<t t-set="mod" t-value="env.ref('base.module_website_event')"/>
|
||||
<div name="module_website_event" t-if='is_system'
|
||||
<div name="module_website_event" t-if='is_system' t-cache="is_system"
|
||||
t-att-data-module-id="mod.id"
|
||||
t-att-data-module-shortdesc="mod.shortdesc"
|
||||
class="col-md-4 mb8 o_new_content_element">
|
||||
@@ -144,7 +143,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<t t-set="mod" t-value="env.ref('base.module_website_forum')"/>
|
||||
<div name="module_website_forum" t-if='is_system'
|
||||
<div name="module_website_forum" t-if='is_system' t-cache="is_system"
|
||||
t-att-data-module-id="mod.id"
|
||||
t-att-data-module-shortdesc="mod.shortdesc"
|
||||
class="col-md-4 mb8 o_new_content_element o_new_content_element_once">
|
||||
@@ -154,7 +153,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<t t-set="mod" t-value="env.ref('base.module_website_hr_recruitment')"/>
|
||||
<div name="module_website_hr_recruitment" t-if='is_system'
|
||||
<div name="module_website_hr_recruitment" t-if='is_system' t-cache="is_system"
|
||||
t-att-data-module-id="mod.id"
|
||||
t-att-data-module-shortdesc="mod.shortdesc"
|
||||
class="col-md-4 mb8 o_new_content_element">
|
||||
@@ -164,7 +163,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<t t-set="mod" t-value="env.ref('base.module_website_sale')"/>
|
||||
<div name="module_website_sale" t-if='is_system'
|
||||
<div name="module_website_sale" t-if='is_system' t-cache="is_system"
|
||||
t-att-data-module-id="mod.id"
|
||||
t-att-data-module-shortdesc="mod.shortdesc"
|
||||
class="col-md-4 mb8 o_new_content_element">
|
||||
@@ -174,7 +173,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<t t-set="mod" t-value="env.ref('base.module_website_slides')"/>
|
||||
<div name="module_website_slides" t-if='is_system'
|
||||
<div name="module_website_slides" t-if='is_system' t-cache="is_system"
|
||||
t-att-data-module-id="mod.id"
|
||||
t-att-data-module-shortdesc="mod.shortdesc"
|
||||
class="col-md-4 mb8 o_new_content_element">
|
||||
@@ -185,7 +184,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<t t-set="mod" t-value="env.ref('base.module_website_livechat')"/>
|
||||
<div name="module_website_livechat" t-if='is_system'
|
||||
<div name="module_website_livechat" t-if='is_system' t-cache="is_system"
|
||||
t-att-data-module-id="mod.id"
|
||||
t-att-data-module-shortdesc="mod.shortdesc"
|
||||
class="col-md-4 mb8 o_new_content_element o_new_content_element_once">
|
||||
|
||||
@@ -39,15 +39,17 @@
|
||||
<t t-set="html_data" t-value="{
|
||||
'lang': lang and lang.replace('_', '-'),
|
||||
'data-website-id': website.id if website else None,
|
||||
'data-edit_translations': '1' if edit_translations else None,
|
||||
}"/>
|
||||
<t t-if="editable or translatable" t-set="nothing" t-value="html_data.update({
|
||||
'data-editable': '1' if editable else None,
|
||||
'data-translatable': '1' if translatable else None,
|
||||
'data-edit_translations': '1' if edit_translations else None,
|
||||
'data-view-xmlid': xmlid if editable or translatable else None,
|
||||
'data-viewid': viewid if editable or translatable else None,
|
||||
'data-main-object': repr(main_object),
|
||||
'data-view-xmlid': xmlid,
|
||||
'data-viewid': viewid,
|
||||
'data-main-object': repr(main_object) if main_object else None,
|
||||
'data-seo-object': repr(seo_object) if seo_object else None,
|
||||
'data-oe-company-name': res_company.name,
|
||||
}"/>
|
||||
})"/>
|
||||
</xpath>
|
||||
<xpath expr="//head" position="before">
|
||||
<t t-if="not title">
|
||||
@@ -70,12 +72,12 @@
|
||||
<t t-set="website_meta" t-value="seo_object and seo_object.get_website_meta() or {}"/>
|
||||
<meta name="default_title" t-att-content="default_title" groups="website.group_website_designer"/>
|
||||
<meta t-if="(main_object and 'website_indexed' in main_object
|
||||
and not main_object.website_indexed) or (website.domain and website.domain not in request.httprequest.url_root)" name="robots" content="noindex"/>
|
||||
<t t-set="seo_object" t-value="seo_object or main_object"/>
|
||||
<t t-set="meta_description" t-value="seo_object and 'website_meta_description' in seo_object
|
||||
and seo_object.website_meta_description or website_meta_description or website_meta.get('meta_description', '')"/>
|
||||
<t t-set="meta_keywords" t-value="seo_object and 'website_meta_keywords' in seo_object
|
||||
and seo_object.website_meta_keywords or website_meta_keywords"/>
|
||||
and not main_object.website_indexed) or (website.domain and website.domain not in request.httprequest.url_root)" name="robots" content="noindex"/>
|
||||
<t t-set="seo_object" t-value="seo_object or main_object"/>
|
||||
<t t-set="meta_description" t-value="seo_object and 'website_meta_description' in seo_object
|
||||
and seo_object.website_meta_description or website_meta_description or website_meta.get('meta_description', '')"/>
|
||||
<t t-set="meta_keywords" t-value="seo_object and 'website_meta_keywords' in seo_object
|
||||
and seo_object.website_meta_keywords or website_meta_keywords"/>
|
||||
<meta t-if="meta_description or editable" name="description" t-att-content="meta_description"/>
|
||||
<meta t-if="meta_keywords or editable" name="keywords" t-att-content="meta_keywords"/>
|
||||
<t t-if="seo_object">
|
||||
@@ -100,13 +102,15 @@
|
||||
<t t-foreach="twitter_meta" t-as="t_meta">
|
||||
<meta t-att-name="t_meta" t-att-content="twitter_meta[t_meta]"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-if="request and request.is_frontend_multilang and website">
|
||||
<t t-set="alternate_languages" t-value="website._get_alternate_languages(canonical_params=canonical_params)"/>
|
||||
<t t-foreach="alternate_languages" t-as="lg">
|
||||
<link rel="alternate" t-att-hreflang="lg['hreflang']" t-att-href="lg['href']"/>
|
||||
<t t-if="request and request.is_frontend_multilang and website">
|
||||
<t t-cache="website,canonical_params">
|
||||
<t t-set="alternate_languages" t-value="website._get_alternate_languages(canonical_params=canonical_params)"/>
|
||||
<t t-foreach="alternate_languages" t-as="lg">
|
||||
<link rel="alternate" t-att-hreflang="lg['hreflang']" t-att-href="lg['href']"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
<link t-if="request and website" rel="canonical" t-att-href="website._get_canonical_url(canonical_params=canonical_params)"/>
|
||||
@@ -124,6 +128,10 @@
|
||||
<attribute name="data-name">Header</attribute>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//header/t[@t-cache]" position="attributes">
|
||||
<attribute name="t-cache">xmlid,website</attribute>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//header" position="before">
|
||||
<t t-set="cta_btn_text" t-value="False"/>
|
||||
<t t-set="cta_btn_href">/contactus</t>
|
||||
@@ -133,6 +141,7 @@
|
||||
<attribute name="data-name">Footer</attribute>
|
||||
<!-- Background now controlled by css configuration, using color combinations -->
|
||||
<attribute name="t-attf-class" add="o_colored_level o_cc" remove="bg-light" separator=" "/>
|
||||
<attribute name="t-cache">no_footer,no_copyright,website</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('o_footer_copyright')]" position="attributes">
|
||||
<attribute name="data-name">Copyright</attribute>
|
||||
@@ -2004,8 +2013,8 @@
|
||||
t-att-data-use_size="use_size"
|
||||
t-att-data-use_filters="use_filters"
|
||||
t-att-data-use_text_align="use_text_align"
|
||||
t-att-data-res-model="request.env.user.has_group('website.group_website_publisher') and _name"
|
||||
t-att-data-res-id="request.env.user.has_group('website.group_website_publisher') and _id"
|
||||
t-att-data-res-model="_name"
|
||||
t-att-data-res-id="_id"
|
||||
t-attf-class="o_record_cover_container d-flex flex-column h-100 o_colored_level o_cc #{_cp.get('background_color_class')} #{use_size and _cp.get('resize_class')} #{use_text_align and _cp.get('text_align_class')} #{additionnal_classes}">
|
||||
<div t-attf-class="o_record_cover_component o_record_cover_image #{snippet_autofocus and 'o_we_snippet_autofocus'}" t-attf-style="background-image: #{_bg};"/>
|
||||
<div t-if="use_filters" t-attf-class="o_record_cover_component o_record_cover_filter oe_black" t-attf-style="opacity: #{_cp.get('opacity', 0.0)};"/>
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytz
|
||||
import babel.dates
|
||||
from collections import OrderedDict
|
||||
|
||||
from odoo import http, fields
|
||||
from odoo import http, fields, tools
|
||||
from odoo.addons.http_routing.models.ir_http import slug, unslug
|
||||
from odoo.addons.website.controllers.main import QueryURL
|
||||
from odoo.addons.portal.controllers.portal import _build_url_w_params
|
||||
@@ -129,20 +129,20 @@ class WebsiteBlog(http.Controller):
|
||||
url_args["date_begin"] = date_begin
|
||||
url_args["date_end"] = date_end
|
||||
|
||||
pager = request.website.pager(
|
||||
pager = tools.lazy(lambda: request.website.pager(
|
||||
url=request.httprequest.path.partition('/page/')[0],
|
||||
total=total,
|
||||
page=page,
|
||||
step=self._blog_post_per_page,
|
||||
url_args=url_args,
|
||||
)
|
||||
))
|
||||
|
||||
if not blogs:
|
||||
all_tags = request.env['blog.tag']
|
||||
else:
|
||||
all_tags = blogs.all_tags(join=True) if not blog else blogs.all_tags().get(blog.id, request.env['blog.tag'])
|
||||
tag_category = sorted(all_tags.mapped('category_id'), key=lambda category: category.name.upper())
|
||||
other_tags = sorted(all_tags.filtered(lambda x: not x.category_id), key=lambda tag: tag.name.upper())
|
||||
all_tags = tools.lazy(lambda: blogs.all_tags(join=True) if not blog else blogs.all_tags().get(blog.id, request.env['blog.tag']))
|
||||
tag_category = tools.lazy(lambda: sorted(all_tags.mapped('category_id'), key=lambda category: category.name.upper()))
|
||||
other_tags = tools.lazy(lambda: sorted(all_tags.filtered(lambda x: not x.category_id), key=lambda tag: tag.name.upper()))
|
||||
|
||||
# for performance prefetch the first post with the others
|
||||
post_ids = (first_post | posts).ids
|
||||
@@ -155,7 +155,7 @@ class WebsiteBlog(http.Controller):
|
||||
'first_post': first_post.with_prefetch(post_ids),
|
||||
'other_tags': other_tags,
|
||||
'tag_category': tag_category,
|
||||
'nav_list': self.nav_list(),
|
||||
'nav_list': self.nav_list,
|
||||
'tags_list': self.tags_list,
|
||||
'pager': pager,
|
||||
'posts': posts.with_prefetch(post_ids),
|
||||
@@ -182,7 +182,7 @@ class WebsiteBlog(http.Controller):
|
||||
], type='http', auth="public", website=True, sitemap=True)
|
||||
def blog(self, blog=None, tag=None, page=1, search=None, **opt):
|
||||
Blog = request.env['blog.blog']
|
||||
blogs = Blog.search(request.website.website_domain(), order="create_date asc, id asc")
|
||||
blogs = tools.lazy(lambda: Blog.search(request.website.website_domain(), order="create_date asc, id asc"))
|
||||
|
||||
if not blog and len(blogs) == 1:
|
||||
return request.redirect('/blog/%s' % slug(blogs[0]), code=302)
|
||||
|
||||
@@ -13,8 +13,8 @@ class TestBlogPerformance(UtilPerf):
|
||||
self.env['website'].search([]).channel_id = False
|
||||
|
||||
def test_10_perf_sql_blog_standard_data(self):
|
||||
self.assertEqual(self._get_url_hot_query('/blog'), 28)
|
||||
self.assertEqual(self._get_url_hot_query('/blog', cache=False), 27)
|
||||
self.assertEqual(self._get_url_hot_query('/blog'), 14)
|
||||
self.assertEqual(self._get_url_hot_query('/blog', cache=False), 14)
|
||||
|
||||
def test_20_perf_sql_blog_bigger_data_scaling(self):
|
||||
BlogPost = self.env['blog.post']
|
||||
@@ -26,10 +26,10 @@ class TestBlogPerformance(UtilPerf):
|
||||
for blog_post in blog_posts:
|
||||
blog_post.tag_ids += blog_tags
|
||||
blog_tags = blog_tags[:-1]
|
||||
self.assertEqual(self._get_url_hot_query('/blog'), 28)
|
||||
self.assertEqual(self._get_url_hot_query('/blog', cache=False), 27)
|
||||
self.assertEqual(self._get_url_hot_query(blog_post[0].website_url), 32)
|
||||
self.assertEqual(self._get_url_hot_query(blog_post[0].website_url, cache=False), 31)
|
||||
self.assertEqual(self._get_url_hot_query('/blog'), 14)
|
||||
self.assertEqual(self._get_url_hot_query('/blog', cache=False), 14)
|
||||
self.assertEqual(self._get_url_hot_query(blog_post[0].website_url), 28)
|
||||
self.assertEqual(self._get_url_hot_query(blog_post[0].website_url, cache=False), 28)
|
||||
|
||||
def test_30_perf_sql_blog_bigger_data_scaling(self):
|
||||
BlogPost = self.env['blog.post']
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="/blog" t-attf-class="nav-link font-weight-bold #{(not blog) and 'active'}">All</a>
|
||||
</li>
|
||||
<li class="nav-item" t-foreach="blogs" t-as="nav_blog">
|
||||
<a t-attf-href="/blog/#{slug(nav_blog)}"
|
||||
<a t-attf-href="/blog/#{slug(nav_blog)}" t-cache="nav_blog,blog and nav_blog.id == blog.id"
|
||||
t-attf-class="nav-link #{(blog and nav_blog.id == blog.id) and 'active' or ''}">
|
||||
<b t-field="nav_blog.name"/>
|
||||
</a>
|
||||
|
||||
@@ -98,7 +98,7 @@ according to the enabled options.
|
||||
<!-- Loop through posts: exclude the first one if already displayed as top banner -->
|
||||
<t t-foreach="posts" t-as="blog_post">
|
||||
<!-- Assign 'colWidth': 'col-12' is default for List-View and mobile -->
|
||||
<div t-attf-class="pb-4 col-12 #{colWidth}">
|
||||
<div t-attf-class="pb-4 col-12 #{colWidth}" t-cache="blog_post,opt_blog_list_view,opt_blog_readable">
|
||||
<article t-attf-class="o_wblog_post position-relative #{'card h-100' if opt_blog_cards_design else ''}" name="blog_post">
|
||||
<!-- List-View Design -->
|
||||
<t t-if="opt_blog_list_view">
|
||||
@@ -201,6 +201,7 @@ according to the enabled options.
|
||||
|
||||
<!-- ====== Sub-Template: Posts list : Posts Teaser + Tags ============= -->
|
||||
<template id="post_teaser">
|
||||
<t t-cache="blog_post,str(active_tag_ids)">
|
||||
<a t-attf-href="/blog/#{slug(blog_post.blog_id)}/#{slug(blog_post)}" class="text-reset text-decoration-none">
|
||||
<div t-if="opt_blog_list_view" t-field="blog_post.teaser" class="mt-2 o_wblog_read_text"/>
|
||||
<div t-else="" t-field="blog_post.teaser" t-attf-class="mt-2 #{opt_blog_readable and 'o_wblog_normalize_font'}"/>
|
||||
@@ -215,6 +216,7 @@ according to the enabled options.
|
||||
t-esc="one_tag.name"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ list of filtered posts (by date or tag).
|
||||
<t t-set="use_text_align" t-value="True"/>
|
||||
<t t-set="additionnal_classes" t-value="'o_wblog_post_page_cover o_record_has_cover cover_auto'"/>
|
||||
|
||||
<div class="container position-relative h-100 d-flex flex-column justify-content-around">
|
||||
<div class="container position-relative h-100 d-flex flex-column justify-content-around" t-cache="_record">
|
||||
<div t-attf-class="o_wblog_post_title #{'js_tweet' if opt_blog_post_select_to_tweet else ''} #{'js_comment' if opt_blog_post_select_to_comment else ''}">
|
||||
<div t-if="not date and not tag and not blog" class="h4 bg-o-color-3 px-2 d-inline-block rounded-sm">Latest</div>
|
||||
<a t-if="not blog and first_post" t-attf-href="/blog/#{slug(first_post.blog_id)}/#{slug(first_post)}" t-att-title="first_post.name" class="text-white text-decoration-none">
|
||||
@@ -218,7 +218,6 @@ list of filtered posts (by date or tag).
|
||||
<t t-set="display_opt_name">Blog Post Cover</t>
|
||||
<t t-set="additionnal_classes" t-value="'o_wblog_post_page_cover'"/>
|
||||
|
||||
|
||||
<div class="container text-center position-relative h-100 d-flex flex-column flex-grow-1 justify-content-around">
|
||||
<div t-attf-class="o_wblog_post_title #{opt_blog_post_select_to_tweet and 'js_tweet'} #{opt_blog_post_select_to_comment and 'js_comment'}">
|
||||
<h1 t-field="blog_post.name" id="o_wblog_post_name" class="o_editable_no_shadow" data-oe-expression="blog_post.name" t-att-data-blog-id="blog_post.id" placeholder="Blog Post Title"/>
|
||||
@@ -413,7 +412,7 @@ list of filtered posts (by date or tag).
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div id="o_wblog_next_container" class="d-flex flex-column">
|
||||
<div id="o_wblog_next_container" class="d-flex flex-column" t-cache="next_post">
|
||||
<t t-call="website.record_cover">
|
||||
<t t-set="_record" t-value="next_post"/>
|
||||
<t t-set="_cp" t-value="json.loads(_record.cover_properties)"/>
|
||||
@@ -478,6 +477,7 @@ list of filtered posts (by date or tag).
|
||||
<attribute name="t-att-data-module-id"/>
|
||||
<attribute name="t-att-data-module-shortdesc"/>
|
||||
<attribute name="t-if">is_designer</attribute>
|
||||
<attribute name="t-cache"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from odoo.addons.website.controllers.main import QueryURL
|
||||
from odoo.http import request
|
||||
from odoo.osv import expression
|
||||
from odoo.tools.misc import get_lang
|
||||
from odoo.tools import lazy
|
||||
|
||||
|
||||
class WebsiteEventController(http.Controller):
|
||||
@@ -177,13 +178,13 @@ class WebsiteEventController(http.Controller):
|
||||
|
||||
def _prepare_event_register_values(self, event, **post):
|
||||
"""Return the require values to render the template."""
|
||||
urls = event._get_event_resource_urls()
|
||||
urls = lazy(event._get_event_resource_urls)
|
||||
return {
|
||||
'event': event,
|
||||
'main_object': event,
|
||||
'range': range,
|
||||
'google_url': urls.get('google_url'),
|
||||
'iCal_url': urls.get('iCal_url'),
|
||||
'google_url': lazy(lambda: urls.get('google_url')),
|
||||
'iCal_url': lazy(lambda: urls.get('iCal_url')),
|
||||
}
|
||||
|
||||
def _process_tickets_form(self, event, form_details):
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
</div>
|
||||
<!-- List -->
|
||||
<div t-foreach="event_ids" t-as="event" t-attf-class=" #{opt_event_size} mb-4">
|
||||
<a t-attf-href="/event/#{ slug(event) }/#{(not event.menu_id) and 'register'}" class="text-decoration-none" t-att-data-publish="event.website_published and 'on' or 'off'">
|
||||
<a t-cache="event if not editable and event.website_published else None" t-attf-href="/event/#{ slug(event) }/#{(not event.menu_id) and 'register'}" class="text-decoration-none" t-att-data-publish="event.website_published and 'on' or 'off'">
|
||||
<article t-attf-class="h-100 #{opt_events_list_cards and 'card border-0 shadow-sm'}" itemscope="itemscope" itemtype="http://schema.org/Event">
|
||||
<div class="h-100 row no-gutters">
|
||||
<!-- Header -->
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<!-- Event - Description -->
|
||||
<template id="event_description_full" name="Event Description" track="1">
|
||||
<t t-call="website_event.event_details">
|
||||
<section class="bg-200 mt-n5">
|
||||
<section class="bg-200 mt-n5" t-cache="event if not editable and event.website_published else None">
|
||||
<div class="container overflow-hidden">
|
||||
<div class="row no-gutters mt-n4 mb-3">
|
||||
<!-- Description -->
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<attribute name="t-att-data-module-id"/>
|
||||
<attribute name="t-att-data-module-shortdesc"/>
|
||||
<attribute name="t-if">is_designer</attribute>
|
||||
<attribute name="t-cache"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ class WebsiteForum(WebsiteProfile):
|
||||
url_args['filters'] = filters
|
||||
if my:
|
||||
url_args['my'] = my
|
||||
pager = request.website.pager(url=url, total=question_count, page=page,
|
||||
pager = tools.lazy(lambda: request.website.pager(url=url, total=question_count, page=page,
|
||||
step=self._post_per_page, scope=self._post_per_page,
|
||||
url_args=url_args)
|
||||
url_args=url_args))
|
||||
|
||||
values = self._prepare_user_values(forum=forum, searches=post, header={'ask_hide': not forum.active})
|
||||
values.update({
|
||||
|
||||
@@ -1706,6 +1706,7 @@
|
||||
<attribute name="t-att-data-module-id"/>
|
||||
<attribute name="t-att-data-module-shortdesc"/>
|
||||
<attribute name="t-if">is_designer</attribute>
|
||||
<attribute name="t-cache"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -603,6 +603,7 @@
|
||||
<attribute name="t-att-data-module-id"/>
|
||||
<attribute name="t-att-data-module-shortdesc"/>
|
||||
<attribute name="t-if">is_designer</attribute>
|
||||
<attribute name="t-cache"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<template id="loader" inherit_id="website.layout" name="Livechat : include loader on Website">
|
||||
<xpath expr="//div[@id='wrapwrap']" position="after">
|
||||
<t t-if="website and website.channel_id and not no_livechat">
|
||||
<t t-if="not no_livechat and website and website.channel_id">
|
||||
<script>
|
||||
<t t-call="im_livechat.loader">
|
||||
<t t-set="info" t-value="website.get_livechat_channel_info()"/>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
from werkzeug.urls import url_decode, url_encode, url_parse
|
||||
|
||||
@@ -19,7 +20,9 @@ from odoo.addons.portal.controllers.portal import _build_url_w_params
|
||||
from odoo.addons.website.controllers import main
|
||||
from odoo.addons.website.controllers.form import WebsiteForm
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import lazy
|
||||
from odoo.tools.json import scriptsafe as json_scriptsafe
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -249,7 +252,12 @@ class WebsiteSale(http.Controller):
|
||||
|
||||
keep = QueryURL('/shop', category=category and int(category), search=search, attrib=attrib_list, min_price=min_price, max_price=max_price, order=post.get('order'))
|
||||
|
||||
pricelist = website.get_current_pricelist()
|
||||
now = datetime.timestamp(datetime.now())
|
||||
pricelist = request.env['product.pricelist'].browse(request.session.get('website_sale_current_pl'))
|
||||
if not pricelist or request.session.get('website_sale_pricelist_time', 0) < now - 60*60: # test: 1 hour in session
|
||||
pricelist = website.get_current_pricelist()
|
||||
request.session['website_sale_pricelist_time'] = now
|
||||
request.session['website_sale_current_pl'] = pricelist.id
|
||||
|
||||
request.update_context(pricelist=pricelist.id, partner=request.env.user.partner_id)
|
||||
|
||||
@@ -349,6 +357,9 @@ class WebsiteSale(http.Controller):
|
||||
layout_mode = 'list'
|
||||
else:
|
||||
layout_mode = 'grid'
|
||||
request.session['website_sale_shop_layout_mode'] = layout_mode
|
||||
|
||||
products_prices = lazy(lambda: products._get_sales_prices(pricelist))
|
||||
|
||||
values = {
|
||||
'search': fuzzy_search_term or search,
|
||||
@@ -361,7 +372,7 @@ class WebsiteSale(http.Controller):
|
||||
'add_qty': add_qty,
|
||||
'products': products,
|
||||
'search_count': product_count, # common for all searchbox
|
||||
'bins': TableCompute().process(products, ppg, ppr),
|
||||
'bins': lazy(lambda: TableCompute().process(products, ppg, ppr)),
|
||||
'ppg': ppg,
|
||||
'ppr': ppr,
|
||||
'categories': categs,
|
||||
@@ -369,6 +380,8 @@ class WebsiteSale(http.Controller):
|
||||
'keep': keep,
|
||||
'search_categories_ids': search_categories.ids,
|
||||
'layout_mode': layout_mode,
|
||||
'products_prices': products_prices,
|
||||
'get_product_prices': lambda product: lazy(lambda: products_prices[product.id]),
|
||||
}
|
||||
if filter_by_price_enabled:
|
||||
values['min_price'] = min_price or available_min_price
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="header_cart_link" name="Header Cart Link">
|
||||
<t t-set="website_sale_order" t-value="website.sale_get_order()" />
|
||||
<t t-set="show_cart" t-value="true"/>
|
||||
<li t-attf-class="#{_item_class} divider d-none"/> <!-- Make sure the cart and related menus are not folded (see autohideMenu) -->
|
||||
<li t-attf-class="o_wsale_my_cart align-self-md-start #{not show_cart and 'd-none'} #{_item_class}">
|
||||
<a href="/shop/cart" t-attf-class="#{_link_class}">
|
||||
<i t-if="_icon" class="fa fa-shopping-cart"/>
|
||||
<span t-if="_text">My Cart</span>
|
||||
<sup class="my_cart_quantity badge badge-primary" t-esc="website_sale_order and website_sale_order.cart_quantity or '0'" t-att-data-order-id="website_sale_order and website_sale_order.id or ''"/>
|
||||
</a>
|
||||
</li>
|
||||
<t t-nocache="The number of products is dynamic, this rendering cannot be cached."
|
||||
t-nocache-_icon="_icon"
|
||||
t-nocache-_item_class="_item_class"
|
||||
t-nocache-_link_class="_link_class">
|
||||
<t t-set="website_sale_order" t-value="website.sale_get_order()" />
|
||||
<t t-set="show_cart" t-value="true"/>
|
||||
<li t-attf-class="#{_item_class} divider d-none"/> <!-- Make sure the cart and related menus are not folded (see autohideMenu) -->
|
||||
<li t-attf-class="o_wsale_my_cart align-self-md-start #{not show_cart and 'd-none'} #{_item_class}">
|
||||
<a href="/shop/cart" t-attf-class="#{_link_class}">
|
||||
<i t-if="_icon" class="fa fa-shopping-cart"/>
|
||||
<span t-if="_text">My Cart</span>
|
||||
<sup class="my_cart_quantity badge badge-primary" t-esc="website_sale_order and website_sale_order.cart_quantity or '0'" t-att-data-order-id="website_sale_order and website_sale_order.id or ''"/>
|
||||
</a>
|
||||
</li>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="header_hide_empty_cart_link" inherit_id="website_sale.header_cart_link" name="Header Hide Empty Cart link" active="False">
|
||||
@@ -156,14 +161,15 @@
|
||||
</template>
|
||||
|
||||
<template id="products_item" name="Products item">
|
||||
<t t-set="product_href" t-value="keep(product.website_url, page=(pager['page']['num'] if pager['page']['num']>1 else None))" />
|
||||
<t t-set="image_type" t-value="product._get_suitable_image_size(ppr, td_product['x'], td_product['y'])"/>
|
||||
|
||||
<form action="/shop/cart/update" method="post" class="oe_product_cart h-100 d-flex"
|
||||
t-att-data-publish="product.website_published and 'on' or 'off'"
|
||||
itemscope="itemscope" itemtype="http://schema.org/Product">
|
||||
|
||||
<t t-set="product_href" t-value="keep(product.website_url, page=(pager['page']['num'] if pager['page']['num']>1 else None))" />
|
||||
<t t-set="image_type" t-value="product._get_suitable_image_size(ppr, td_product['x'], td_product['y'])"/>
|
||||
|
||||
<div class="oe_product_image position-relative h-100 flex-grow-0 overflow-hidden">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" />
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" t-nocache="The csrf token must always be up to date."/>
|
||||
<a t-att-href="product_href" class="oe_product_image_link d-block h-100 position-relative" itemprop="url" contenteditable="false">
|
||||
<t t-set="image_holder" t-value="product._get_image_holder()"/>
|
||||
<span t-field="image_holder.image_1920"
|
||||
@@ -186,6 +192,7 @@
|
||||
</h6>
|
||||
</div>
|
||||
<div class="o_wsale_product_sub d-flex justify-content-between align-items-end pb-1">
|
||||
<t t-set="template_price_vals" t-value="get_product_prices(product)"/>
|
||||
<div class="o_wsale_product_btn"/>
|
||||
<div class="product_price" itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer">
|
||||
<t t-if="'base_price' in template_price_vals">
|
||||
@@ -279,29 +286,27 @@
|
||||
<t t-set="_classes" t-valuef="w-100 w-md-auto mr-auto mb-2"/>
|
||||
<t t-set="search" t-value="original_search or search"/>
|
||||
</t>
|
||||
<t t-call="website_sale.pricelist_list">
|
||||
<t t-call="website_sale.pricelist_list" t-cache="pricelist">
|
||||
<t t-set="_classes" t-valuef="ml-3 mb-2"/>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="original_search and bins" class="alert alert-warning mt8">
|
||||
<div t-if="original_search and products" class="alert alert-warning mt8">
|
||||
No results found for '<span t-esc="original_search"/>'. Showing results for '<span t-esc="search"/>'.
|
||||
</div>
|
||||
<t t-if="category">
|
||||
<t t-set='editor_msg'>Drag building blocks here to customize the header for "<t t-esc='category.name'/>" category.</t>
|
||||
<div class="mb16" id="category_header" t-att-data-editor-message="editor_msg" t-field="category.website_description"/>
|
||||
</t>
|
||||
<div t-if="bins" class="o_wsale_products_grid_table_wrapper">
|
||||
<div t-cache="pricelist,products" t-if="products" class="o_wsale_products_grid_table_wrapper">
|
||||
<table class="table table-borderless m-0" t-att-data-ppg="ppg" t-att-data-ppr="ppr" t-att-data-default-sort="website.shop_default_sort" t-att-data-name="grid_block_name">
|
||||
<colgroup t-ignore="true">
|
||||
<!-- Force the number of columns (useful when only one row of (x < ppr) products) -->
|
||||
<col t-foreach="ppr" t-as="p"/>
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<t t-set="products_prices" t-value="products._get_sales_prices(pricelist)"/>
|
||||
<tr t-foreach="bins" t-as="tr_product">
|
||||
<t t-foreach="tr_product" t-as="td_product">
|
||||
<t t-if="td_product">
|
||||
<t t-set="product" t-value="td_product['product']" />
|
||||
<!-- We use t-attf-class here to allow easier customization -->
|
||||
<td t-att-colspan="td_product['x'] != 1 and td_product['x']"
|
||||
t-att-rowspan="td_product['y'] != 1 and td_product['y']"
|
||||
@@ -310,7 +315,7 @@
|
||||
t-att-data-name="product_block_name">
|
||||
<div t-attf-class="o_wsale_product_grid_wrapper position-relative h-100 o_wsale_product_grid_wrapper_#{td_product['x']}_#{td_product['y']}">
|
||||
<t t-call="website_sale.products_item">
|
||||
<t t-set="template_price_vals" t-value="products_prices[product.id]"/>
|
||||
<t t-set="product" t-value="td_product['product']"/>
|
||||
</t>
|
||||
</div>
|
||||
</td>
|
||||
@@ -598,7 +603,7 @@
|
||||
</template>
|
||||
|
||||
<template id="product" name="Product" track="1">
|
||||
|
||||
<t t-cache="pricelist,product">
|
||||
<t t-set="combination" t-value="product._get_first_possible_combination()"/>
|
||||
<t t-set="combination_info" t-value="product._get_combination_info(combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
|
||||
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
|
||||
@@ -719,6 +724,7 @@
|
||||
<div class="oe_structure oe_empty oe_structure_not_nearest mt16" id="oe_structure_website_sale_product_2" data-editor-message="DROP BUILDING BLOCKS HERE TO MAKE THEM AVAILABLE ACROSS ALL PRODUCTS"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="product_custom_text" inherit_id="website_sale.product" active="True" customize_show="True" name="Terms and Conditions" priority="21">
|
||||
@@ -1992,6 +1998,7 @@
|
||||
<attribute name="t-att-data-module-id"/>
|
||||
<attribute name="t-att-data-module-shortdesc"/>
|
||||
<attribute name="t-if">env.user.has_group('sales_team.group_sale_manager')</attribute>
|
||||
<attribute name="t-cache"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<odoo>
|
||||
<template id="product_wishlist" inherit_id="website_sale_wishlist.product_wishlist">
|
||||
<xpath expr="//button[hasclass('o_wish_rm')]" position="before">
|
||||
<small class="text-danger d-md-block" t-if="wish.product_id.product_tmpl_id._is_sold_out()">Temporarily out of stock</small>
|
||||
<small class="text-danger d-md-block" t-if="wish.product_id.sudo()._is_sold_out()">Temporarily out of stock</small>
|
||||
</xpath>
|
||||
<xpath expr="//button[hasclass('o_wish_rm')]" position="after">
|
||||
<t t-set="notify" t-value="wish.stock_notification"/>
|
||||
<button groups="base.group_no_one" t-att-data-notify="notify" t-if="notify or wish.product_id.product_tmpl_id._is_sold_out()" type="button" class="btn btn-link o_notify_stock no-decoration">
|
||||
<button groups="base.group_no_one" t-att-data-notify="notify" t-if="notify or wish.product_id.sudo()._is_sold_out()" type="button" class="btn btn-link o_notify_stock no-decoration">
|
||||
<small><i t-attf-class="fa #{'fa-check-square-o' if notify else 'fa-square-o'}"></i> Be notified when back in stock</small>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
@@ -52,7 +52,7 @@ tour.register('shop_wishlist', {
|
||||
},
|
||||
{
|
||||
content: "click on Customizable Desk (TEST)",
|
||||
trigger: '.oe_product_cart a:contains("Customizable Desk (TEST)")',
|
||||
trigger: '.oe_product_cart a:contains("Customizable Desk")',
|
||||
},
|
||||
{
|
||||
content: "check the first variant is already in wishlist",
|
||||
@@ -76,7 +76,7 @@ tour.register('shop_wishlist', {
|
||||
},
|
||||
{
|
||||
content: "remove Customizable Desk (TEST)",
|
||||
trigger: 'tr:contains("Customizable Desk (TEST)") .o_wish_rm:first',
|
||||
trigger: 'tr:contains("Customizable Desk") .o_wish_rm:first',
|
||||
},
|
||||
{
|
||||
content: "check that wishlist contains 1 item",
|
||||
@@ -338,6 +338,69 @@ tour.register('shop_wishlist', {
|
||||
trigger: '#product_detail:not(:has(.o_add_wishlist_dyn))',
|
||||
run: function () {},
|
||||
},
|
||||
// Test if the wishlist button is active or not in /shop
|
||||
{
|
||||
content: "Go to '/shop?search=Customizable Desk'",
|
||||
trigger: 'body',
|
||||
run: function () {
|
||||
window.location.href = '/shop?search=Customizable Desk '
|
||||
},
|
||||
},
|
||||
{
|
||||
content: "Click on the product",
|
||||
trigger: '.oe_product_image_link img',
|
||||
},
|
||||
{
|
||||
content: "Add the product in the wishlist",
|
||||
trigger: '#product_option_block .o_add_wishlist_dyn',
|
||||
},
|
||||
{
|
||||
content: "Added into the wishlist",
|
||||
trigger: '.my_wish_quantity.badge-primary:contains(1)',
|
||||
run: function () {},
|
||||
},
|
||||
{
|
||||
content: "Go to '/shop",
|
||||
trigger: '#top_menu_collapse a[href="/shop"]',
|
||||
},
|
||||
{
|
||||
content: "Search the product Customizable Desk'",
|
||||
trigger: 'form.o_wsale_products_searchbar_form input',
|
||||
run: function () {
|
||||
$('form.o_wsale_products_searchbar_form input[name="search"]').val("Customizable Desk");
|
||||
$('form.o_wsale_products_searchbar_form button').click();
|
||||
},
|
||||
},
|
||||
{
|
||||
content: "The product is in the wishlist",
|
||||
trigger: '.oe_product_cart .o_wsale_product_information:has(.o_add_wishlist[disabled])',
|
||||
run: function () {},
|
||||
},
|
||||
{
|
||||
content: "Go to the wishlist",
|
||||
trigger: 'a[href="/shop/wishlist"]',
|
||||
},
|
||||
{
|
||||
content: "Remove the product from the wishlist",
|
||||
trigger: '.o_wish_rm',
|
||||
},
|
||||
{
|
||||
content: "Go to '/shop",
|
||||
trigger: '#top_menu_collapse a[href="/shop"]',
|
||||
},
|
||||
{
|
||||
content: "Search the product Customizable Desk'",
|
||||
trigger: 'form.o_wsale_products_searchbar_form input',
|
||||
run: function () {
|
||||
$('form.o_wsale_products_searchbar_form input[name="search"]').val("Customizable Desk");
|
||||
$('form.o_wsale_products_searchbar_form button').click();
|
||||
},
|
||||
},
|
||||
{
|
||||
content: "The product is not in the wishlist",
|
||||
trigger: '.oe_product_cart .o_wsale_product_information:not(:has(.o_add_wishlist[disabled]))',
|
||||
run: function () {},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -3,30 +3,42 @@
|
||||
|
||||
<template id="add_to_wishlist" inherit_id="website_sale.products_item" customize_show="True" name="Wishlist Button" priority="20">
|
||||
<xpath expr="//div[hasclass('o_wsale_product_btn')]" position="inside">
|
||||
<t t-set="in_wish" t-value="product._is_in_wishlist()"/>
|
||||
<t t-set="product_variant_id" t-value="product._get_first_possible_variant_id()"/>
|
||||
<button t-if="product_variant_id" type="button" role="button" class="btn btn-outline-primary bg-white o_add_wishlist" t-att-disabled='in_wish or None' title="Add to Wishlist" t-att-data-product-template-id="product.id" t-att-data-product-product-id="product_variant_id" data-action="o_wishlist"><span class="fa fa-heart" role="img" aria-label="Add to wishlist"></span></button>
|
||||
<t t-nocache="The wishlist depends on the user and must not be shared with other users. As this rendering is in the loop, the product value is set in the template, we must retain the id of the product on which we are."
|
||||
t-nocache-product_template_id="product.id">
|
||||
<t t-set="product" t-value="products.filtered(lambda p: p.id == product_template_id)"/>
|
||||
<t t-set="in_wish" t-value="product._is_in_wishlist()"/>
|
||||
<t t-set="product_variant_id" t-value="product._get_first_possible_variant_id()"/>
|
||||
<button t-if="product_variant_id" type="button" role="button" class="btn btn-outline-primary bg-white o_add_wishlist" t-att-disabled='in_wish or None' title="Add to Wishlist" t-att-data-product-template-id="product.id" t-att-data-product-product-id="product_variant_id" data-action="o_wishlist"><span class="fa fa-heart" role="img" aria-label="Add to wishlist"></span></button>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="product_add_to_wishlist" inherit_id="website_sale.product" customize_show="True" name="Wishlist Button" priority="20">
|
||||
<xpath expr="//div[@id='product_option_block']" position="inside">
|
||||
<t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
|
||||
<t t-set="in_wish" t-value="product_variant and product_variant._is_in_wishlist()"/>
|
||||
<button t-if="product_variant" type="button" role="button" class="btn btn-link px-0 pr-3 o_add_wishlist_dyn" t-att-disabled='in_wish or None' t-att-data-product-template-id="product.id" t-att-data-product-product-id="product_variant.id" data-action="o_wishlist" title="Add to wishlist"><i class="fa fa-heart-o mr-2" role="img" aria-label="Add to wishlist"/>Add to wishlist</button>
|
||||
<t t-nocache="The wishlist depends on the user and must not be shared with other users. The product come from the controller.">
|
||||
<t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
|
||||
<t t-set="in_wish" t-value="product_variant and product_variant._is_in_wishlist()"/>
|
||||
<button t-if="product_variant" type="button" role="button" class="btn btn-link px-0 pr-3 o_add_wishlist_dyn" t-att-disabled='in_wish or None' t-att-data-product-template-id="product.id" t-att-data-product-product-id="product_variant.id" data-action="o_wishlist" title="Add to wishlist"><i class="fa fa-heart-o mr-2" role="img" aria-label="Add to wishlist"/>Add to wishlist</button>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="header_wishlist_link" name="Header Wishlist Link">
|
||||
<t t-set="wishcount" t-value="len(request.env['product.wishlist'].current())"/>
|
||||
<t t-set="show_wishes" t-value="True"/>
|
||||
<li t-attf-class="o_wsale_my_wish #{not show_wishes and 'd-none'} #{_item_class}">
|
||||
<a href="/shop/wishlist" t-attf-class="#{_link_class}">
|
||||
<i t-if="_icon" class="fa fa-1x fa-heart"/>
|
||||
<span t-if="_text">Wishlist</span>
|
||||
<sup t-esc="wishcount" t-attf-class="my_wish_quantity o_animate_blink badge badge-primary"/>
|
||||
</a>
|
||||
</li>
|
||||
<t t-nocache="The wishlist may vary and depends on the user."
|
||||
t-nocache-_icon="_icon"
|
||||
t-nocache-_item_class="_item_class"
|
||||
t-nocache-_link_class="_link_class"
|
||||
t-nocache-_text="_text">
|
||||
<t t-set="wishcount" t-value="len(request.env['product.wishlist'].current())"/>
|
||||
<t t-set="show_wishes" t-value="True"/>
|
||||
<li t-attf-class="o_wsale_my_wish #{not show_wishes and 'd-none'} #{_item_class}">
|
||||
<a href="/shop/wishlist" t-attf-class="#{_link_class}">
|
||||
<i t-if="_icon" class="fa fa-1x fa-heart"/>
|
||||
<span t-if="_text">Wishlist</span>
|
||||
<sup t-esc="wishcount" t-attf-class="my_wish_quantity o_animate_blink badge badge-primary"/>
|
||||
</a>
|
||||
</li>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="header_hide_empty_wishlist_link" inherit_id="website_sale_wishlist.header_wishlist_link">
|
||||
|
||||
@@ -295,34 +295,33 @@ class WebsiteSlides(WebsiteProfile):
|
||||
@http.route('/slides', type='http', auth="public", website=True, sitemap=True)
|
||||
def slides_channel_home(self, **post):
|
||||
""" Home page for eLearning platform. Is mainly a container page, does not allow search / filter. """
|
||||
domain = request.website.website_domain()
|
||||
channels_all = request.env['slide.channel'].search(domain)
|
||||
channels_all = tools.lazy(lambda: request.env['slide.channel'].search(request.website.website_domain()))
|
||||
if not request.env.user._is_public():
|
||||
#If a course is completed, we don't want to see it in first position but in last
|
||||
channels_my = channels_all.filtered(lambda channel: channel.is_member).sorted(lambda channel: 0 if channel.completed else channel.completion, reverse=True)[:3]
|
||||
channels_my = tools.lazy(lambda: channels_all.filtered(lambda channel: channel.is_member).sorted(lambda channel: 0 if channel.completed else channel.completion, reverse=True)[:3])
|
||||
else:
|
||||
channels_my = request.env['slide.channel']
|
||||
channels_popular = channels_all.sorted('total_votes', reverse=True)[:3]
|
||||
channels_newest = channels_all.sorted('create_date', reverse=True)[:3]
|
||||
channels_popular = tools.lazy(lambda: channels_all.sorted('total_votes', reverse=True)[:3])
|
||||
channels_newest = tools.lazy(lambda: channels_all.sorted('create_date', reverse=True)[:3])
|
||||
|
||||
achievements = request.env['gamification.badge.user'].sudo().search([('badge_id.is_published', '=', True)], limit=5)
|
||||
achievements = tools.lazy(lambda: request.env['gamification.badge.user'].sudo().search([('badge_id.is_published', '=', True)], limit=5))
|
||||
if request.env.user._is_public():
|
||||
challenges = None
|
||||
challenges_done = None
|
||||
else:
|
||||
challenges = request.env['gamification.challenge'].sudo().search([
|
||||
challenges = tools.lazy(lambda: request.env['gamification.challenge'].sudo().search([
|
||||
('challenge_category', '=', 'slides'),
|
||||
('reward_id.is_published', '=', True)
|
||||
], order='id asc', limit=5)
|
||||
challenges_done = request.env['gamification.badge.user'].sudo().search([
|
||||
], order='id asc', limit=5))
|
||||
challenges_done = tools.lazy(lambda: request.env['gamification.badge.user'].sudo().search([
|
||||
('challenge_id', 'in', challenges.ids),
|
||||
('user_id', '=', request.env.user.id),
|
||||
('badge_id.is_published', '=', True)
|
||||
]).mapped('challenge_id')
|
||||
]).mapped('challenge_id'))
|
||||
|
||||
users = request.env['res.users'].sudo().search([
|
||||
users = tools.lazy(lambda: request.env['res.users'].sudo().search([
|
||||
('karma', '>', 0),
|
||||
('website_published', '=', True)], limit=5, order='karma desc')
|
||||
('website_published', '=', True)], limit=5, order='karma desc'))
|
||||
|
||||
render_values = self._slide_render_context_base()
|
||||
render_values.update(self._prepare_user_values(**post))
|
||||
@@ -332,7 +331,7 @@ class WebsiteSlides(WebsiteProfile):
|
||||
'channels_newest': channels_newest,
|
||||
'achievements': achievements,
|
||||
'users': users,
|
||||
'top3_users': self._get_top3_users(),
|
||||
'top3_users': tools.lazy(self._get_top3_users),
|
||||
'challenges': challenges,
|
||||
'challenges_done': challenges_done,
|
||||
'search_tags': request.env['slide.channel.tag'],
|
||||
@@ -1330,7 +1329,7 @@ class WebsiteSlides(WebsiteProfile):
|
||||
if kwargs.get('channel'):
|
||||
channels = kwargs['channel']
|
||||
elif kwargs.get('channel_id'):
|
||||
channels = request.env['slide.channel'].browse(int(kwargs['channel_id']))
|
||||
channels = tools.lazy(lambda: request.env['slide.channel'].browse(int(kwargs['channel_id'])))
|
||||
return channels
|
||||
|
||||
def _prepare_user_slides_profile(self, user):
|
||||
|
||||
@@ -15,10 +15,10 @@ tour.register('course_publisher_standard', {
|
||||
test: true
|
||||
}, [{
|
||||
content: 'eLearning: click on New (top-menu)',
|
||||
trigger: 'div.o_new_content_menu a'
|
||||
trigger: '#new-content-menu > a'
|
||||
}, {
|
||||
content: 'eLearning: click on New Course',
|
||||
trigger: 'a:contains("Course")'
|
||||
trigger: '#o_new_content_menu_choices a:contains("Course")'
|
||||
}, {
|
||||
content: 'eLearning: set name',
|
||||
trigger: 'input[name="name"]',
|
||||
|
||||
@@ -15,10 +15,10 @@ tour.register('course_publisher', {
|
||||
test: true
|
||||
}, [{
|
||||
content: 'eLearning: click on New (top-menu)',
|
||||
trigger: 'div.o_new_content_menu a'
|
||||
trigger: '#new-content-menu > a'
|
||||
}, {
|
||||
content: 'eLearning: click on New Course',
|
||||
trigger: 'a:contains("Course")'
|
||||
trigger: '#o_new_content_menu_choices a:contains("Course")'
|
||||
}, {
|
||||
content: 'eLearning: set name',
|
||||
trigger: 'input[name="name"]',
|
||||
|
||||
@@ -329,7 +329,7 @@
|
||||
</template>
|
||||
|
||||
<template id='course_card' name="Course Card">
|
||||
<div t-attf-class="card w-100 o_wslides_course_card mb-4 #{'o_wslides_course_unpublished' if not channel.is_published else ''}">
|
||||
<div t-attf-class="card w-100 o_wslides_course_card mb-4 #{'o_wslides_course_unpublished' if not channel.is_published else ''}" t-cache="channel if is_public_user else None">
|
||||
<t t-set="course_image" t-value="website.image_url(channel, 'image_1024')"/>
|
||||
<t t-set="channel_frontend_tags" t-value="channel.tag_ids.filtered(lambda tag: tag.color)"/>
|
||||
<a t-attf-href="/slides/#{slug(channel)}" t-title="channel.name">
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
<attribute name="t-att-data-module-id"/>
|
||||
<attribute name="t-att-data-module-shortdesc"/>
|
||||
<attribute name="t-if">env.user.has_group('website_slides.group_website_slides_officer')</attribute>
|
||||
<attribute name="t-cache"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user