diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 3053ad286ee..4d00f5114f8 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -36,7 +36,7 @@ import odoo import odoo.modules.registry from odoo.api import call_kw, Environment from odoo.modules import get_module_path, get_resource_path -from odoo.tools import image_process, topological_sort, html_escape, pycompat +from odoo.tools import image_process, topological_sort, html_escape, pycompat, ustr from odoo.tools.mimetypes import guess_mimetype from odoo.tools.translate import _ from odoo.tools.misc import str2bool, xlwt, file_open @@ -63,6 +63,9 @@ env.filters["json"] = json.dumps # 1 week cache for asset bundles as advised by Google Page Speed BUNDLE_MAXAGE = 60 * 60 * 24 * 7 +# 1 year cache for content (menus, translations, static qweb) +CONTENT_MAXAGE = 60 * 60 * 24 * 356 + DBNAME_PATTERN = '^[a-zA-Z0-9][a-zA-Z0-9_.-]+$' #---------------------------------------------------------- @@ -451,6 +454,23 @@ class Home(http.Controller): except AccessError: return werkzeug.utils.redirect('/web/login?error=access') + @http.route('/web/webclient/load_menus/', type='http', auth='user', methods=['GET']) + def web_load_menus(self, unique): + """ + Loads the menus for the webclient + :param unique: this parameters is not used, but mandatory: it is used by the HTTP stack to make a unique request + :return: the menus (including the images in Base64) + """ + menus = request.env["ir.ui.menu"].load_menus(request.debug) + body = json.dumps(menus, default=ustr) + response = request.make_response(body, [ + # this method must specify a content-type application/json instead of using the default text/html set because + # the type of the route is set to HTTP, but the rpc is made with a get and expects JSON + ('Content-Type', 'application/json'), + ('Cache-Control', 'public, max-age=31536000'), + ]) + return response + @http.route('/web/dbredirect', type='http', auth="none") def web_db_redirect(self, redirect='/', **kw): ensure_db() @@ -549,18 +569,14 @@ class WebClient(http.Controller): ('Cache-Control', 'max-age=36000'), ]) - @http.route('/web/webclient/qweb', type='http', auth="none", cors="*") - def qweb(self, mods=None, db=None): + @http.route('/web/webclient/qweb/', type='http', auth="none", cors="*") + def qweb(self, unique, mods=None, db=None): files = [f[0] for f in manifest_glob('qweb', addons=mods, db=db)] - last_modified = get_last_modified(files) - if request.httprequest.if_modified_since and request.httprequest.if_modified_since >= last_modified: - return werkzeug.wrappers.Response(status=304) - - content, checksum = concat_xml(files) - - return make_conditional( - request.make_response(content, [('Content-Type', 'text/xml')]), - last_modified, checksum) + content, _dummy = concat_xml(files) + return request.make_response(content, [ + ('Content-Type', 'text/xml'), + ('Cache-Control','public, max-age=' + str(CONTENT_MAXAGE)) + ]) @http.route('/web/webclient/bootstrap_translations', type='json', auth="none") def bootstrap_translations(self, mods): @@ -585,41 +601,32 @@ class WebClient(http.Controller): return {"modules": translations_per_module, "lang_parameters": None} - @http.route('/web/webclient/translations', type='json', auth="none") - def translations(self, mods=None, lang=None): - request.disable_db = False - if mods is None: - mods = [x['name'] for x in request.env['ir.module.module'].sudo().search_read( - [('state', '=', 'installed')], ['name'])] - if lang is None: - lang = request.context["lang"] - langs = request.env['res.lang'].sudo().search([("code", "=", lang)]) - lang_params = None - if langs: - lang_params = langs.read([ - "name", "direction", "date_format", "time_format", - "grouping", "decimal_point", "thousands_sep", "week_start"])[0] - lang_params['week_start'] = int(lang_params['week_start']) + @http.route('/web/webclient/translations/', type='http', auth="none") + def translations(self, unique, mods=None, lang=None): + """ + Load the translations for the specified language and modules - # Regional languages (ll_CC) must inherit/override their parent lang (ll), but this is - # done server-side when the language is loaded, so we only need to load the user's lang. - translations_per_module = {} - messages = request.env['ir.translation'].sudo().search_read([ - ('module', 'in', mods), ('lang', '=', lang), - ('comments', 'like', 'openerp-web'), ('value', '!=', False), - ('value', '!=', '')], - ['module', 'src', 'value', 'lang'], order='module') - for mod, msg_group in itertools.groupby(messages, key=operator.itemgetter('module')): - translations_per_module.setdefault(mod, {'messages': []}) - translations_per_module[mod]['messages'].extend({ - 'id': m['src'], - 'string': m['value']} - for m in msg_group) - return { + :param unique: this parameters is not used, but mandatory: it is used by the HTTP stack to make a unique request + :param mods: the modules, a comma separated list + :param lang: the language of the user + :return: + """ + request.disable_db = False + + translations_per_module, lang_params = request.env["ir.translation"].get_translations_for_webclient(mods, lang) + + body = json.dumps({ 'lang_parameters': lang_params, 'modules': translations_per_module, 'multi_lang': len(request.env['res.lang'].sudo().get_installed()) > 1, - } + }) + response = request.make_response(body, [ + # this method must specify a content-type application/json instead of using the default text/html set because + # the type of the route is set to HTTP, but the rpc is made with a get and expects JSON + ('Content-Type', 'application/json'), + ('Cache-Control', 'public, max-age=' + str(CONTENT_MAXAGE)), + ]) + return response @http.route('/web/webclient/version_info', type='json', auth="none") def version_info(self): diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py index 15fc49cff29..0253599d508 100644 --- a/addons/web/models/ir_http.py +++ b/addons/web/models/ir_http.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import hashlib import json from odoo import models from odoo.http import request +from odoo.tools import ustr + +from odoo.addons.web.controllers.main import concat_xml, manifest_glob, module_boot import odoo @@ -21,6 +24,19 @@ class Http(models.AbstractModel): def session_info(self): user = request.env.user version_info = odoo.service.common.exp_version() + + user_context = request.session.get_context() if request.session.uid else {} + + mods = ','.join(module_boot()) + files = [f[0] for f in manifest_glob('qweb', addons=mods)] + _, qweb_checksum = concat_xml(files) + + lang = user_context.get("lang") + translations_per_module, _ = request.env['ir.translation'].get_translations_for_webclient(mods, lang) + + menu_json_utf8 = json.dumps(request.env['ir.ui.menu'].load_menus(request.debug), default=ustr, sort_keys=True).encode() + translations_json_utf8 = json.dumps(translations_per_module, sort_keys=True).encode() + return { "uid": request.session.uid, "is_system": user._is_system() if request.session.uid else False, @@ -41,7 +57,11 @@ class Http(models.AbstractModel): "show_effect": True, "display_switch_company_menu": user.has_group('base.group_multi_company') and len(user.company_ids) > 1, "toggle_company": user.has_group('base.group_toggle_company'), - + "cache_hashes": { + "load_menus": hashlib.sha1(menu_json_utf8).hexdigest(), + "qweb": qweb_checksum, + "translations": hashlib.sha1(translations_json_utf8).hexdigest(), + }, } def get_currencies(self): diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index 360d44743b5..f1af6fc7234 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -101,7 +101,7 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi KeyboardNavigationMixin.init.call(this); this.origin = undefined; this._current_state = null; - this.menu_dm = new concurrency.DropMisordered(); + this.menu_dp = new concurrency.DropPrevious(); this.action_mutex = new concurrency.Mutex(); this.set('title_part', {"zopenerp": "Odoo"}); }, diff --git a/addons/web/static/src/js/chrome/web_client.js b/addons/web/static/src/js/chrome/web_client.js index 19ce0a92d41..e79f7d84289 100644 --- a/addons/web/static/src/js/chrome/web_client.js +++ b/addons/web/static/src/js/chrome/web_client.js @@ -55,12 +55,7 @@ return AbstractWebClient.extend({ }); }, load_menus: function () { - return this._rpc({ - model: 'ir.ui.menu', - method: 'load_menus', - args: [config.debug], - context: session.user_context, - }) + return (odoo.loadMenusPromise || odoo.reloadMenus()) .then(function (menuData) { // Compute action_id if not defined on a top menu item for (var i = 0; i < menuData.children.length; i++) { @@ -75,6 +70,7 @@ return AbstractWebClient.extend({ } } } + odoo.loadMenusPromise = null; return menuData; }); }, @@ -82,18 +78,18 @@ return AbstractWebClient.extend({ var self = this; this.set_title(); - return this.instanciate_menu_widgets().then(function () { + return this.menu_dp.add(this.instanciate_menu_widgets()).then(function () { $(window).bind('hashchange', self.on_hashchange); // If the url's state is empty, we execute the user's home action if there is one (we // show the first app if not) var state = $.bbq.getState(true); if (_.keys(state).length === 1 && _.keys(state)[0] === "cids") { - return self._rpc({ + return self.menu_dp.add(self._rpc({ model: 'res.users', method: 'read', args: [session.uid, ["action_id"]], - }) + })) .then(function (result) { var data = result[0]; if (data.action_id) { @@ -143,7 +139,7 @@ return AbstractWebClient.extend({ if (!_.isEqual(self._current_state, stringstate)) { var state = $.bbq.getState(true); if (state.action || (state.model && (state.view_type || state.id))) { - return self.action_manager.loadState(state, !!self._current_state).then(function () { + return self.menu_dp.add(self.action_manager.loadState(state, !!self._current_state)).then(function () { if (state.menu_id) { if (state.menu_id !== self.menu.current_primary_menu) { core.bus.trigger('change_menu_section', state.menu_id); @@ -158,7 +154,7 @@ return AbstractWebClient.extend({ }); } else if (state.menu_id) { var action_id = self.menu.menu_id_to_action_id(state.menu_id); - return self.do_action(action_id, {clear_breadcrumbs: true}).then(function () { + return self.menu_dp.add(self.do_action(action_id, {clear_breadcrumbs: true})).then(function () { core.bus.trigger('change_menu_section', state.menu_id); }); } else { @@ -179,7 +175,7 @@ return AbstractWebClient.extend({ // -------------------------------------------------------------- on_app_clicked: function (ev) { var self = this; - return this.menu_dm.add(data_manager.load_action(ev.data.action_id)) + return this.menu_dp.add(data_manager.load_action(ev.data.action_id)) .then(function (result) { return self.action_mutex.exec(function () { var completed = new Promise(function (resolve, reject) { @@ -210,7 +206,7 @@ return AbstractWebClient.extend({ }, on_menu_clicked: function (ev) { var self = this; - return this.menu_dm.add(data_manager.load_action(ev.data.action_id)) + return this.menu_dp.add(data_manager.load_action(ev.data.action_id)) .then(function (result) { self.$el.removeClass('o_mobile_menu_opened'); diff --git a/addons/web/static/src/js/core/session.js b/addons/web/static/src/js/core/session.js index 21103a9529a..e083bb71e7f 100644 --- a/addons/web/static/src/js/core/session.js +++ b/addons/web/static/src/js/core/session.js @@ -241,8 +241,11 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, { }); }, load_qweb: function (mods) { + var self = this; var lock = this.qweb_mutex.exec(function () { - return $.get('/web/webclient/qweb?mods=' + mods).then(function (doc) { + var cacheId = self.cache_hashes && self.cache_hashes.qweb; + var route = '/web/webclient/qweb/' + (cacheId ? cacheId : Date.now()) + '?mods=' + mods; + return $.get(route).then(function (doc) { if (!doc) { return; } qweb.add_template(doc); }); @@ -344,10 +347,18 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, { getTZOffset: function (date) { return -new Date(date).getTimezoneOffset(); }, - //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- + /** + * Replaces the value of a key in cache_hashes (the hash of some resource computed on the back-end by a unique value + * @param {string} key the key in the cache_hashes to invalidate + */ + invalidateCacheKey: function(key) { + if (this.cache_hashes && this.cache_hashes[key]) { + this.cache_hashes[key] = Date.now(); + } + }, /** * Reload the currencies (initially given in session_info). This is meant to diff --git a/addons/web/static/src/js/core/translation.js b/addons/web/static/src/js/core/translation.js index 83c556c3c37..416ea9130d2 100644 --- a/addons/web/static/src/js/core/translation.js +++ b/addons/web/static/src/js/core/translation.js @@ -62,9 +62,12 @@ var TranslationDataBase = Class.extend(/** @lends instance.TranslationDataBase# */ load_translations: function(session, modules, lang, url) { var self = this; - return session.rpc(url || '/web/webclient/translations', { - "mods": modules || null, - "lang": lang || null + var cacheId = session.cache_hashes && session.cache_hashes.translations; + url = url || '/web/webclient/translations'; + url += '/' + (cacheId ? cacheId : Date.now()); + return $.get(url, { + mods: modules ? modules.join(',') : null, + lang: lang || null, }).then(function (trans) { self.set_bundle(trans); }); diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js index fb9a29a01de..4d66a59eac2 100644 --- a/addons/web/static/src/js/services/data_manager.js +++ b/addons/web/static/src/js/services/data_manager.js @@ -4,6 +4,7 @@ odoo.define('web.DataManager', function (require) { var config = require('web.config'); var core = require('web.core'); var rpc = require('web.rpc'); +var session = require('web.session'); var utils = require('web.utils'); return core.Class.extend({ @@ -26,6 +27,7 @@ return core.Class.extend({ * Suggestion: could be refined to invalidate some part of the cache */ invalidate: function () { + session.invalidateCacheKey('load_menus'); this._init_cache(); }, @@ -45,7 +47,7 @@ return core.Class.extend({ route: "/web/action/load", params: { action_id: action_id, - additional_context : additional_context, + additional_context: additional_context, }, }).then(function (action) { self._cache.actions[key] = action.no_cache ? null : self._cache.actions[key]; diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 22ddf28a6ce..d175360a2da 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -747,8 +747,21 @@ - diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py index bf560adf467..0f5b23ad22a 100644 --- a/odoo/addons/base/models/ir_translation.py +++ b/odoo/addons/base/models/ir_translation.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import itertools import logging +import operator from collections import defaultdict from difflib import get_close_matches @@ -851,3 +852,37 @@ class IrTranslation(models.Model): ('name', 'like', model_name+','), ], } + + @api.model + def get_translations_for_webclient(self, mods, lang): + if mods is None: + mods = [x['name'] for x in self.env['ir.module.module'].sudo().search_read( + [('state', '=', 'installed')], ['name'])] + else: + mods = mods.split(',') + if lang is None: + lang = self._context["lang"] + langs = self.env['res.lang'].sudo().search([("code", "=", lang)]) + lang_params = None + if langs: + lang_params = langs.read([ + "name", "direction", "date_format", "time_format", + "grouping", "decimal_point", "thousands_sep", "week_start"])[0] + lang_params['week_start'] = int(lang_params['week_start']) + + # Regional languages (ll_CC) must inherit/override their parent lang (ll), but this is + # done server-side when the language is loaded, so we only need to load the user's lang. + translations_per_module = {} + messages = self.env['ir.translation'].sudo().search_read([ + ('module', 'in', mods), ('lang', '=', lang), + ('comments', 'like', 'openerp-web'), ('value', '!=', False), + ('value', '!=', '')], + ['module', 'src', 'value', 'lang'], order='module') + for mod, msg_group in itertools.groupby(messages, key=operator.itemgetter('module')): + translations_per_module.setdefault(mod, {'messages': []}) + translations_per_module[mod]['messages'].extend({ + 'id': m['src'], + 'string': m['value']} + for m in msg_group) + + return translations_per_module, lang_params