summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/framework
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src/js/framework')
-rw-r--r--addons/web/static/src/js/framework/ajax.js286
-rw-r--r--addons/web/static/src/js/framework/class.js154
-rw-r--r--addons/web/static/src/js/framework/core.js191
-rw-r--r--addons/web/static/src/js/framework/crash_manager.js195
-rw-r--r--addons/web/static/src/js/framework/data.js1117
-rw-r--r--addons/web/static/src/js/framework/dialog.js194
-rw-r--r--addons/web/static/src/js/framework/formats.js189
-rw-r--r--addons/web/static/src/js/framework/misc.js339
-rw-r--r--addons/web/static/src/js/framework/mixins.js328
-rw-r--r--addons/web/static/src/js/framework/model.js191
-rw-r--r--addons/web/static/src/js/framework/pyeval.js1018
-rw-r--r--addons/web/static/src/js/framework/registry.js54
-rw-r--r--addons/web/static/src/js/framework/session.js393
-rw-r--r--addons/web/static/src/js/framework/time.js285
-rw-r--r--addons/web/static/src/js/framework/translation.js98
-rw-r--r--addons/web/static/src/js/framework/utils.js383
-rw-r--r--addons/web/static/src/js/framework/view.js239
-rw-r--r--addons/web/static/src/js/framework/widget.js367
18 files changed, 6021 insertions, 0 deletions
diff --git a/addons/web/static/src/js/framework/ajax.js b/addons/web/static/src/js/framework/ajax.js
new file mode 100644
index 00000000000..00da4610f9c
--- /dev/null
+++ b/addons/web/static/src/js/framework/ajax.js
@@ -0,0 +1,286 @@
+odoo.define('web.ajax', ['web.time'], function (require) {
+"use strict";
+
+var time = require('web.time');
+
+function genericJsonRpc (fct_name, params, fct) {
+ var data = {
+ jsonrpc: "2.0",
+ method: fct_name,
+ params: params,
+ id: Math.floor(Math.random() * 1000 * 1000 * 1000)
+ };
+ var xhr = fct(data);
+ var result = xhr.pipe(function(result) {
+ if (result.error !== undefined) {
+ console.error("Server application error", result.error);
+ return $.Deferred().reject("server", result.error);
+ } else {
+ return result.result;
+ }
+ }, function() {
+ //console.error("JsonRPC communication error", _.toArray(arguments));
+ var def = $.Deferred();
+ return def.reject.apply(def, ["communication"].concat(_.toArray(arguments)));
+ });
+ // FIXME: jsonp?
+ result.abort = function () { if (xhr.abort) xhr.abort(); };
+ return result;
+}
+
+function jsonRpc(url, fct_name, params, settings) {
+ return genericJsonRpc(fct_name, params, function(data) {
+ return $.ajax(url, _.extend({}, settings, {
+ url: url,
+ dataType: 'json',
+ type: 'POST',
+ data: JSON.stringify(data, time.date_to_utc),
+ contentType: 'application/json'
+ }));
+ });
+};
+
+function jsonpRpc(url, fct_name, params, settings) {
+ settings = settings || {};
+ return genericJsonRpc(fct_name, params, function(data) {
+ var payload_str = JSON.stringify(data, time.date_to_utc);
+ var payload_url = $.param({r:payload_str});
+ var force2step = settings.force2step || false;
+ delete settings.force2step;
+ var session_id = settings.session_id || null;
+ delete settings.session_id;
+ if (payload_url.length < 2000 && ! force2step) {
+ return $.ajax(url, _.extend({}, settings, {
+ url: url,
+ dataType: 'jsonp',
+ jsonp: 'jsonp',
+ type: 'GET',
+ cache: false,
+ data: {r: payload_str, session_id: session_id}
+ }));
+ } else {
+ var args = {session_id: session_id, id: data.id};
+ var ifid = _.uniqueId('oe_rpc_iframe');
+ var html = "<iframe src='javascript:false;' name='" + ifid + "' id='" + ifid + "' style='display:none'></iframe>";
+ var $iframe = $(html);
+ var nurl = 'jsonp=1&' + $.param(args);
+ nurl = url.indexOf("?") !== -1 ? url + "&" + nurl : url + "?" + nurl;
+ var $form = $('<form>')
+ .attr('method', 'POST')
+ .attr('target', ifid)
+ .attr('enctype', "multipart/form-data")
+ .attr('action', nurl)
+ .append($('<input type="hidden" name="r" />').attr('value', payload_str))
+ .hide()
+ .appendTo($('body'));
+ var cleanUp = function() {
+ if ($iframe) {
+ $iframe.unbind("load").remove();
+ }
+ $form.remove();
+ };
+ var deferred = $.Deferred();
+ // the first bind is fired up when the iframe is added to the DOM
+ $iframe.bind('load', function() {
+ // the second bind is fired up when the result of the form submission is received
+ $iframe.unbind('load').bind('load', function() {
+ $.ajax({
+ url: url,
+ dataType: 'jsonp',
+ jsonp: 'jsonp',
+ type: 'GET',
+ cache: false,
+ data: {session_id: session_id, id: data.id}
+ }).always(function() {
+ cleanUp();
+ }).done(function() {
+ deferred.resolve.apply(deferred, arguments);
+ }).fail(function() {
+ deferred.reject.apply(deferred, arguments);
+ });
+ });
+ // now that the iframe can receive data, we fill and submit the form
+ $form.submit();
+ });
+ // append the iframe to the DOM (will trigger the first load)
+ $form.after($iframe);
+ if (settings.timeout) {
+ realSetTimeout(function() {
+ deferred.reject({});
+ }, settings.timeout);
+ }
+ return deferred;
+ }
+ });
+};
+
+// helper
+function realSetTimeout (fct, millis) {
+ var finished = new Date().getTime() + millis;
+ var wait = function() {
+ var current = new Date().getTime();
+ if (current < finished) {
+ setTimeout(wait, finished - current);
+ } else {
+ fct();
+ }
+ };
+ setTimeout(wait, millis);
+}
+
+function loadCSS(url) {
+ if (!$('link[href="' + url + '"]').length) {
+ $('head').append($('<link>', {
+ 'href': url,
+ 'rel': 'stylesheet',
+ 'type': 'text/css'
+ }));
+ }
+};
+
+function loadJS(url) {
+ var def = $.Deferred();
+ if ($('script[src="' + url + '"]').length) {
+ def.resolve();
+ } else {
+ var script = document.createElement('script');
+ script.type = 'text/javascript';
+ script.src = url;
+ script.onload = script.onreadystatechange = function() {
+ if ((script.readyState && script.readyState != "loaded" && script.readyState != "complete") || script.onload_done) {
+ return;
+ }
+ script.onload_done = true;
+ def.resolve(url);
+ };
+ script.onerror = function () {
+ console.error("Error loading file", script.src);
+ def.reject(url);
+ };
+ var head = document.head || document.getElementsByTagName('head')[0];
+ head.appendChild(script);
+ }
+ return def;
+};
+
+/**
+ * Cooperative file download implementation, for ajaxy APIs.
+ *
+ * Requires that the server side implements an httprequest correctly
+ * setting the `fileToken` cookie to the value provided as the `token`
+ * parameter. The cookie *must* be set on the `/` path and *must not* be
+ * `httpOnly`.
+ *
+ * It would probably also be a good idea for the response to use a
+ * `Content-Disposition: attachment` header, especially if the MIME is a
+ * "known" type (e.g. text/plain, or for some browsers application/json
+ *
+ * @param {Object} options
+ * @param {String} [options.url] used to dynamically create a form
+ * @param {Object} [options.data] data to add to the form submission. If can be used without a form, in which case a form is created from scratch. Otherwise, added to form data
+ * @param {HTMLFormElement} [options.form] the form to submit in order to fetch the file
+ * @param {Function} [options.success] callback in case of download success
+ * @param {Function} [options.error] callback in case of request error, provided with the error body
+ * @param {Function} [options.complete] called after both ``success`` and ``error` callbacks have executed
+ */
+function get_file(options) {
+ // need to detect when the file is done downloading (not used
+ // yet, but we'll need it to fix the UI e.g. with a throbber
+ // while dump is being generated), iframe load event only fires
+ // when the iframe content loads, so we need to go smarter:
+ // http://geekswithblogs.net/GruffCode/archive/2010/10/28/detecting-the-file-download-dialog-in-the-browser.aspx
+ var timer, token = new Date().getTime(),
+ cookie_name = 'fileToken', cookie_length = cookie_name.length,
+ CHECK_INTERVAL = 1000, id = _.uniqueId('get_file_frame'),
+ remove_form = false;
+
+
+ // iOS devices doesn't allow iframe use the way we do it,
+ // opening a new window seems the best way to workaround
+ if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) {
+ var params = _.extend({}, options.data || {}, {token: token});
+ var url = this.url(options.url, params);
+ return window.open(url);
+ }
+
+ var $form, $form_data = $('<div>');
+
+ var complete = function () {
+ if (options.complete) { options.complete(); }
+ clearTimeout(timer);
+ $form_data.remove();
+ $target.remove();
+ if (remove_form && $form) { $form.remove(); }
+ };
+ var $target = $('<iframe style="display: none;">')
+ .attr({id: id, name: id})
+ .appendTo(document.body)
+ .load(function () {
+ try {
+ if (options.error) {
+ var body = this.contentDocument.body;
+ var nodes = body.children.length === 0 ? body.childNodes : body.children;
+ var node = nodes[1] || nodes[0];
+ options.error(JSON.parse(node.textContent));
+ }
+ } finally {
+ complete();
+ }
+ });
+
+ if (options.form) {
+ $form = $(options.form);
+ } else {
+ remove_form = true;
+ $form = $('<form>', {
+ action: options.url,
+ method: 'POST'
+ }).appendTo(document.body);
+ }
+
+ var hparams = _.extend({}, options.data || {}, {token: token});
+ _.each(hparams, function (value, key) {
+ var $input = $form.find('[name=' + key +']');
+ if (!$input.length) {
+ $input = $('<input type="hidden" name="' + key + '">')
+ .appendTo($form_data);
+ }
+ $input.val(value);
+ });
+
+ $form
+ .append($form_data)
+ .attr('target', id)
+ .get(0).submit();
+
+ var waitLoop = function () {
+ var cookies = document.cookie.split(';');
+ // setup next check
+ timer = setTimeout(waitLoop, CHECK_INTERVAL);
+ for (var i=0; i<cookies.length; ++i) {
+ var cookie = cookies[i].replace(/^\s*/, '');
+ if (!cookie.indexOf(cookie_name === 0)) { continue; }
+ var cookie_val = cookie.substring(cookie_length + 1);
+ if (parseInt(cookie_val, 10) !== token) { continue; }
+
+ // clear cookie
+ document.cookie = _.str.sprintf("%s=;expires=%s;path=/",
+ cookie_name, new Date().toGMTString());
+ if (options.success) { options.success(); }
+ complete();
+ return;
+ }
+ };
+ timer = setTimeout(waitLoop, CHECK_INTERVAL);
+};
+
+
+return {
+ jsonRpc: jsonRpc,
+ jsonpRpc: jsonpRpc,
+ loadCSS: loadCSS,
+ loadJS: loadJS,
+ get_file: get_file,
+};
+
+});
diff --git a/addons/web/static/src/js/framework/class.js b/addons/web/static/src/js/framework/class.js
new file mode 100644
index 00000000000..4de1afc66d9
--- /dev/null
+++ b/addons/web/static/src/js/framework/class.js
@@ -0,0 +1,154 @@
+odoo.define('web.Class', function () {
+"use strict";
+/**
+ * Improved John Resig's inheritance, based on:
+ *
+ * Simple JavaScript Inheritance
+ * By John Resig http://ejohn.org/
+ * MIT Licensed.
+ *
+ * Adds "include()"
+ *
+ * Defines The Class object. That object can be used to define and inherit classes using
+ * the extend() method.
+ *
+ * Example:
+ *
+ * var Person = openerp.Class.extend({
+ * init: function(isDancing){
+ * this.dancing = isDancing;
+ * },
+ * dance: function(){
+ * return this.dancing;
+ * }
+ * });
+ *
+ * The init() method act as a constructor. This class can be instancied this way:
+ *
+ * var person = new Person(true);
+ * person.dance();
+ *
+ * The Person class can also be extended again:
+ *
+ * var Ninja = Person.extend({
+ * init: function(){
+ * this._super( false );
+ * },
+ * dance: function(){
+ * // Call the inherited version of dance()
+ * return this._super();
+ * },
+ * swingSword: function(){
+ * return true;
+ * }
+ * });
+ *
+ * When extending a class, each re-defined method can use this._super() to call the previous
+ * implementation of that method.
+ */
+var initializing = false;
+var fnTest = /xyz/.test(function(){xyz();}) ? /\b_super\b/ : /.*/;
+
+// The web Class implementation (does nothing)
+function OdooClass(){};
+
+/**
+ * Subclass an existing class
+ *
+ * @param {Object} prop class-level properties (class attributes and instance methods) to set on the new class
+ */
+OdooClass.extend = function() {
+ var _super = this.prototype;
+ // Support mixins arguments
+ var args = _.toArray(arguments);
+ args.unshift({});
+ var prop = _.extend.apply(_,args);
+
+ // Instantiate a web class (but only create the instance,
+ // don't run the init constructor)
+ initializing = true;
+ var This = this;
+ var prototype = new This();
+ initializing = false;
+
+ // Copy the properties over onto the new prototype
+ _.each(prop, function(val, name) {
+ // Check if we're overwriting an existing function
+ prototype[name] = typeof prop[name] == "function" &&
+ fnTest.test(prop[name]) ?
+ (function(name, fn) {
+ return function() {
+ var tmp = this._super;
+
+ // Add a new ._super() method that is the same
+ // method but on the super-class
+ this._super = _super[name];
+
+ // The method only need to be bound temporarily, so
+ // we remove it when we're done executing
+ var ret = fn.apply(this, arguments);
+ this._super = tmp;
+
+ return ret;
+ };
+ })(name, prop[name]) :
+ prop[name];
+ });
+
+ // The dummy class constructor
+ function Class() {
+ if(this.constructor !== OdooClass){
+ throw new Error("You can only instanciate objects with the 'new' operator");
+ }
+ // All construction is actually done in the init method
+ this._super = null;
+ if (!initializing && this.init) {
+ var ret = this.init.apply(this, arguments);
+ if (ret) { return ret; }
+ }
+ return this;
+ }
+ Class.include = function (properties) {
+ _.each(properties, function(val, name) {
+ if (typeof properties[name] !== 'function'
+ || !fnTest.test(properties[name])) {
+ prototype[name] = properties[name];
+ } else if (typeof prototype[name] === 'function'
+ && prototype.hasOwnProperty(name)) {
+ prototype[name] = (function (name, fn, previous) {
+ return function () {
+ var tmp = this._super;
+ this._super = previous;
+ var ret = fn.apply(this, arguments);
+ this._super = tmp;
+ return ret;
+ };
+ })(name, properties[name], prototype[name]);
+ } else if (typeof _super[name] === 'function') {
+ prototype[name] = (function (name, fn) {
+ return function () {
+ var tmp = this._super;
+ this._super = _super[name];
+ var ret = fn.apply(this, arguments);
+ this._super = tmp;
+ return ret;
+ };
+ })(name, properties[name]);
+ }
+ });
+ };
+
+ // Populate our constructed prototype object
+ Class.prototype = prototype;
+
+ // Enforce the constructor to be what we expect
+ Class.constructor = Class;
+
+ // And make this class extendable
+ Class.extend = this.extend;
+
+ return Class;
+};
+
+return OdooClass;
+});
diff --git a/addons/web/static/src/js/framework/core.js b/addons/web/static/src/js/framework/core.js
new file mode 100644
index 00000000000..5bfe975c7fd
--- /dev/null
+++ b/addons/web/static/src/js/framework/core.js
@@ -0,0 +1,191 @@
+odoo.define('web.core', ['web.Class', 'web.mixins', 'web.Registry', 'web.translation', 'qweb', '_', '$'], function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var mixins = require('web.mixins');
+var Registry = require('web.Registry');
+var translation = require('web.translation');
+
+var qweb = require('qweb');
+var _ = require('_');
+var $ = require('$');
+
+var debug = $.deparam($.param.querystring()).debug !== undefined;
+
+var _t = translation._t;
+var _lt = translation._lt;
+
+/**
+ * Event Bus used to bind events scoped in the current instance
+ */
+var Bus = Class.extend(mixins.EventDispatcherMixin, {
+ init: function() {
+ mixins.EventDispatcherMixin.init.call(this, parent);
+ var self = this;
+ // TODO fme: allow user to bind keys for some global actions.
+ // check gtk bindings
+ // http://unixpapa.com/js/key.html
+ _.each('click,dblclick,keydown,keypress,keyup'.split(','), function(evtype) {
+ $('html').on(evtype, function(ev) {
+ self.trigger(evtype, ev);
+ });
+ });
+ _.each('resize,scroll'.split(','), function(evtype) {
+ $(window).on(evtype, function(ev) {
+ self.trigger(evtype, ev);
+ });
+ });
+ },
+});
+
+// Underscore customization
+//-------------------------------------------------------------------------
+_.str.toBoolElse = function (str, elseValues, trueValues, falseValues) {
+ var ret = _.str.toBool(str, trueValues, falseValues);
+ if (_.isUndefined(ret)) {
+ return elseValues;
+ }
+ return ret;
+};
+
+// IE patch
+//-------------------------------------------------------------------------
+if (typeof(console) === "undefined") {
+ // Even IE9 only exposes console object if debug window opened
+ window.console = {};
+ ('log error debug info warn assert clear dir dirxml trace group'
+ + ' groupCollapsed groupEnd time timeEnd profile profileEnd count'
+ + ' exception').split(/\s+/).forEach(function(property) {
+ console[property] = _.identity;
+ });
+}
+
+/**
+ Some hack to make placeholders work in ie9.
+*/
+if (!('placeholder' in document.createElement('input'))) {
+ document.addEventListener("DOMNodeInserted",function(event){
+ var nodename = event.target.nodeName.toLowerCase();
+ if ( nodename === "input" || nodename == "textarea" ) {
+ $(event.target).placeholder();
+ }
+ });
+}
+
+
+
+/**
+ * Lazy translation function, only performs the translation when actually
+ * printed (e.g. inserted into a template)
+ *
+ * Useful when defining translatable strings in code evaluated before the
+ * translation database is loaded, as class attributes or at the top-level of
+ * an OpenERP Web module
+ *
+ * @param {String} s string to translate
+ * @returns {Object} lazy translation object
+ */
+qweb.debug = debug;
+_.extend(qweb.default_dict, {
+ '__debug__': debug,
+ 'moment': function(date) { return new moment(date); },
+});
+
+qweb.preprocess_node = function() {
+ // Note that 'this' is the Qweb Node
+ switch (this.node.nodeType) {
+ case Node.TEXT_NODE:
+ case Node.CDATA_SECTION_NODE:
+ // Text and CDATAs
+ var translation = this.node.parentNode.attributes['t-translation'];
+ if (translation && translation.value === 'off') {
+ return;
+ }
+ var match = /^(\s*)([\s\S]+?)(\s*)$/.exec(this.node.data);
+ if (match) {
+ this.node.data = match[1] + _t(match[2]) + match[3];
+ }
+ break;
+ case Node.ELEMENT_NODE:
+ // Element
+ var attr, attrs = ['label', 'title', 'alt', 'placeholder'];
+ while ((attr = attrs.pop())) {
+ if (this.attributes[attr]) {
+ this.attributes[attr] = _t(this.attributes[attr]);
+ }
+ }
+ }
+};
+
+/** Setup jQuery timeago */
+/*
+ * Strings in timeago are "composed" with prefixes, words and suffixes. This
+ * makes their detection by our translating system impossible. Use all literal
+ * strings we're using with a translation mark here so the extractor can do its
+ * job.
+ */
+{
+ _t('less than a minute ago');
+ _t('about a minute ago');
+ _t('%d minutes ago');
+ _t('about an hour ago');
+ _t('%d hours ago');
+ _t('a day ago');
+ _t('%d days ago');
+ _t('about a month ago');
+ _t('%d months ago');
+ _t('about a year ago');
+ _t('%d years ago');
+}
+
+$.async_when = function() {
+ var async = false;
+ var def = $.Deferred();
+ $.when.apply($, arguments).done(function() {
+ var args = arguments;
+ var action = function() {
+ def.resolve.apply(def, args);
+ };
+ if (async)
+ action();
+ else
+ setTimeout(action, 0);
+ }).fail(function() {
+ var args = arguments;
+ var action = function() {
+ def.reject.apply(def, args);
+ };
+ if (async)
+ action();
+ else
+ setTimeout(action, 0);
+ });
+ async = true;
+ return def;
+};
+
+return {
+ debug: debug,
+ qweb: qweb,
+
+ // core classes and functions
+ Class: Class,
+ mixins: mixins,
+ bus: new Bus (),
+ _t: _t,
+ _lt: _lt,
+
+ // registries
+ view_registry: new Registry(),
+ crash_registry: new Registry(),
+ action_registry : new Registry(),
+ form_widget_registry: new Registry(),
+ form_tag_registry: new Registry(),
+ form_custom_registry: new Registry(),
+ list_widget_registry: new Registry(),
+ search_widgets_registry: new Registry(),
+ search_filters_registry: new Registry(),
+};
+
+
+});
diff --git a/addons/web/static/src/js/framework/crash_manager.js b/addons/web/static/src/js/framework/crash_manager.js
new file mode 100644
index 00000000000..416ef4b4136
--- /dev/null
+++ b/addons/web/static/src/js/framework/crash_manager.js
@@ -0,0 +1,195 @@
+odoo.define('web.crash_manager', ['web.CrashManager'], function (require) {
+ var CrashManager = require('web.CrashManager');
+ return new CrashManager();
+});
+
+odoo.define('web.CrashManager', ['web.ajax', 'web.core', 'web.Dialog', 'web.session'], function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var session = require('web.session');
+
+var QWeb = core.qweb;
+var _t = core._t;
+var _lt = core._lt;
+
+var map_title ={
+ user_error: _lt('Warning'),
+ warning: _lt('Warning'),
+ access_error: _lt('Access Error'),
+ missing_error: _lt('Missing Record'),
+ validation_error: _lt('Validation Error'),
+ except_orm: _lt('Global Business Error'),
+ access_denied: _lt('Access Denied'),
+};
+
+var CrashManager = core.Class.extend({
+ init: function() {
+ this.active = true;
+ },
+ enable: function () {
+ this.active = true;
+ },
+ disable: function () {
+ this.active = false;
+ },
+ rpc_error: function(error) {
+ if (!this.active) {
+ return;
+ }
+ if (error.code == -32098) {
+ $.blockUI({ message: '' , overlayCSS: {'z-index': 9999, backgroundColor: '#FFFFFF', opacity: 0.0, cursor: 'wait'}});
+ var $indicator = $('<div class="oe_indicator">' + _t("Trying to reconnect... ") + '<i class="fa fa-refresh fa-spin"></i></div>');
+ $indicator.prependTo("body");
+ var timeinterval = setInterval(function(){
+ ajax.jsonRpc('/web/webclient/version_info').then(function() {
+ clearInterval(timeinterval);
+ $indicator.html(_t("You are back online"));
+ $indicator.delay(2000).fadeOut('slow',function(){
+ $indicator.remove();
+ });
+ $.unblockUI();
+ });
+ }, 2000);
+ return;
+ }
+ var handler = core.crash_registry.get(error.data.name, true);
+ if (handler) {
+ new (handler)(this, error).display();
+ return;
+ }
+ if (error.data.name === "openerp.http.SessionExpiredException" || error.data.name === "werkzeug.exceptions.Forbidden") {
+ this.show_warning({type: "Session Expired", data: { message: _t("Your Odoo session expired. Please refresh the current web page.") }});
+ return;
+ }
+ if (_.has(map_title, error.data.exception_type)) {
+ if(error.data.exception_type == 'except_orm'){
+ if(error.data.arguments[1]) {
+ error = _.extend({}, error,
+ {
+ data: _.extend({}, error.data,
+ {
+ message: error.data.arguments[1],
+ title: error.data.arguments[0] !== 'Warning' ? (" - " + error.data.arguments[0]) : '',
+ })
+ });
+ }
+ else {
+ error = _.extend({}, error,
+ {
+ data: _.extend({}, error.data,
+ {
+ message: error.data.arguments[0],
+ title: '',
+ })
+ });
+ }
+ }
+ else {
+ error = _.extend({}, error,
+ {
+ data: _.extend({}, error.data,
+ {
+ message: error.data.arguments[0],
+ title: map_title[error.data.exception_type] !== 'Warning' ? (" - " + map_title[error.data.exception_type]) : '',
+ })
+ });
+ }
+
+ this.show_warning(error);
+ //InternalError
+
+ } else {
+ this.show_error(error);
+ }
+ },
+ show_warning: function(error) {
+ if (!this.active) {
+ return;
+ }
+ new Dialog(this, {
+ size: 'medium',
+ title: "Odoo " + (_.str.capitalize(error.type) || _t("Warning")),
+ subtitle: error.data.title,
+ buttons: [
+ {text: _t("Ok"), click: function() { this.parents('.modal').modal('hide'); }}
+ ],
+ }, $('<div>' + QWeb.render('CrashManager.warning', {error: error}) + '</div>')).open();
+ },
+ show_error: function(error) {
+ if (!this.active) {
+ return;
+ }
+ var buttons = {};
+ buttons[_t("Ok")] = function() {
+ this.parents('.modal').modal('hide');
+ };
+ new Dialog(this, {
+ title: "Odoo " + _.str.capitalize(error.type),
+ buttons: buttons
+ }, QWeb.render('CrashManager.error', {session: session, error: error})).open();
+ },
+ show_message: function(exception) {
+ this.show_error({
+ type: _t("Client Error"),
+ message: exception,
+ data: {debug: ""}
+ });
+ },
+});
+
+/**
+ An interface to implement to handle exceptions. Register implementation in instance.web.crash_manager_registry.
+*/
+var ExceptionHandler = {
+ /**
+ @param parent The parent.
+ @param error The error object as returned by the JSON-RPC implementation.
+ */
+ init: function(parent, error) {},
+ /**
+ Called to inform to display the widget, if necessary. A typical way would be to implement
+ this interface in a class extending instance.web.Dialog and simply display the dialog in this
+ method.
+ */
+ display: function() {},
+};
+
+
+/**
+ * Handle redirection warnings, which behave more or less like a regular
+ * warning, with an additional redirection button.
+ */
+var RedirectWarningHandler = Dialog.extend(ExceptionHandler, {
+ init: function(parent, error) {
+ this._super(parent);
+ this.error = error;
+ },
+ display: function() {
+ var self = this;
+ var error = this.error;
+ error.data.message = error.data.arguments[0];
+
+ new Dialog(this, {
+ size: 'medium',
+ title: "Odoo " + (_.str.capitalize(error.type) || "Warning"),
+ buttons: [
+ {text: error.data.arguments[2],
+ oe_link_class : 'oe_highlight',
+ post_text : _t("or"),
+ click: function() {
+ window.location.href='#action='+error.data.arguments[1];
+ self.destroy();
+ }},
+ {text: _t("Cancel"), oe_link_class: 'oe_link', click: function() { self.$el.parents('.modal').modal('hide'); self.destroy();}}
+ ],
+ }, QWeb.render('CrashManager.warning', {error: error})).open();
+ }
+});
+
+core.crash_registry.add('openerp.exceptions.RedirectWarning', RedirectWarningHandler);
+
+return CrashManager;
+});
diff --git a/addons/web/static/src/js/framework/data.js b/addons/web/static/src/js/framework/data.js
new file mode 100644
index 00000000000..f8f091be80d
--- /dev/null
+++ b/addons/web/static/src/js/framework/data.js
@@ -0,0 +1,1117 @@
+odoo.define('web.data', ['web.core', 'web.Model', 'web.session', 'web.pyeval'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var Model = require('web.Model');
+var session = require('web.session');
+var pyeval = require('web.pyeval');
+
+var Class = core.Class;
+var mixins = core.mixins;
+var _t = core._t;
+
+/**
+ * Serializes the sort criterion array of a dataset into a form which can be
+ * consumed by OpenERP's RPC APIs.
+ *
+ * @param {Array} criterion array of fields, from first to last criteria, prefixed with '-' for reverse sorting
+ * @returns {String} SQL-like sorting string (``ORDER BY``) clause
+ */
+function serialize_sort(criterion) {
+ return _.map(criterion,
+ function (criteria) {
+ if (criteria[0] === '-') {
+ return criteria.slice(1) + ' DESC';
+ }
+ return criteria + ' ASC';
+ }).join(', ');
+}
+
+/**
+ * Reverse of the serialize_sort function: convert an array of SQL-like sort
+ * descriptors into a list of fields prefixed with '-' if necessary.
+ */
+function deserialize_sort(criterion) {
+ return _.map(criterion, function (criteria) {
+ var split = _.without(criteria.split(' '), '');
+ return (split[1] && split[1].toLowerCase() === 'desc' ? '-' : '') + split[0];
+ });
+}
+
+var Query = Class.extend({
+ init: function (model, fields) {
+ this._model = model;
+ this._fields = fields;
+ this._filter = [];
+ this._context = {};
+ this._lazy = true;
+ this._limit = false;
+ this._offset = 0;
+ this._order_by = [];
+ },
+ clone: function (to_set) {
+ to_set = to_set || {};
+ var q = new Query(this._model, this._fields);
+ q._context = this._context;
+ q._filter = this._filter;
+ q._lazy = this._lazy;
+ q._limit = this._limit;
+ q._offset = this._offset;
+ q._order_by = this._order_by;
+
+ for(var key in to_set) {
+ if (!to_set.hasOwnProperty(key)) { continue; }
+ switch(key) {
+ case 'filter':
+ q._filter = new CompoundDomain(
+ q._filter, to_set.filter);
+ break;
+ case 'context':
+ q._context = new CompoundContext(
+ q._context, to_set.context);
+ break;
+ case 'lazy':
+ case 'limit':
+ case 'offset':
+ case 'order_by':
+ q['_' + key] = to_set[key];
+ }
+ }
+ return q;
+ },
+ _execute: function (options) {
+ var self = this;
+ options = options || {};
+ return session.rpc('/web/dataset/search_read', {
+ model: this._model.name,
+ fields: this._fields || false,
+ domain: pyeval.eval('domains',
+ [this._model.domain(this._filter)]),
+ context: pyeval.eval('contexts',
+ [this._model.context(this._context)]),
+ offset: this._offset,
+ limit: this._limit,
+ sort: serialize_sort(this._order_by)
+ }, options).then(function (results) {
+ self._count = results.length;
+ return results.records;
+ }, null);
+ },
+ /**
+ * Fetches the first record matching the query, or null
+ *
+ * @param {Object} [options] additional options for the rpc() method
+ * @returns {jQuery.Deferred<Object|null>}
+ */
+ first: function (options) {
+ var self = this;
+ return this.clone({limit: 1})._execute(options).then(function (records) {
+ delete self._count;
+ if (records.length) { return records[0]; }
+ return null;
+ });
+ },
+ /**
+ * Fetches all records matching the query
+ *
+ * @param {Object} [options] additional options for the rpc() method
+ * @returns {jQuery.Deferred<Array<>>}
+ */
+ all: function (options) {
+ return this._execute(options);
+ },
+ /**
+ * Fetches the number of records matching the query in the database
+ *
+ * @returns {jQuery.Deferred<Number>}
+ */
+ count: function () {
+ if (this._count !== undefined) { return $.when(this._count); }
+ return this._model.call(
+ 'search_count', [this._filter], {
+ context: this._model.context(this._context)});
+ },
+ /**
+ * Performs a groups read according to the provided grouping criterion
+ *
+ * @param {String|Array<String>} grouping
+ * @returns {jQuery.Deferred<Array<openerp.web.QueryGroup>> | null}
+ */
+ group_by: function (grouping) {
+ var ctx = pyeval.eval(
+ 'context', this._model.context(this._context));
+
+ // undefined passed in explicitly (!)
+ if (_.isUndefined(grouping)) {
+ grouping = [];
+ }
+
+ if (!(grouping instanceof Array)) {
+ grouping = _.toArray(arguments);
+ }
+ if (_.isEmpty(grouping) && !ctx.group_by_no_leaf) {
+ return null;
+ }
+ var raw_fields = _.map(grouping.concat(this._fields || []), function (field) {
+ return field.split(':')[0];
+ });
+
+ var self = this;
+ return this._model.call('read_group', {
+ groupby: grouping,
+ fields: _.uniq(raw_fields),
+ domain: this._model.domain(this._filter),
+ context: ctx,
+ offset: this._offset,
+ lazy: this._lazy,
+ limit: this._limit,
+ orderby: serialize_sort(this._order_by) || false
+ }).then(function (results) {
+ return _(results).map(function (result) {
+ // FIX: querygroup initialization
+ result.__context = result.__context || {};
+ result.__context.group_by = result.__context.group_by || [];
+ _.defaults(result.__context, ctx);
+ var grouping_fields = self._lazy ? [grouping[0]] : grouping;
+ return new QueryGroup(
+ self._model.name, grouping_fields, result);
+ });
+ });
+ },
+ /**
+ * Creates a new query with the union of the current query's context and
+ * the new context.
+ *
+ * @param context context data to add to the query
+ * @returns {openerp.web.Query}
+ */
+ context: function (context) {
+ if (!context) { return this; }
+ return this.clone({context: context});
+ },
+ /**
+ * Creates a new query with the union of the current query's filter and
+ * the new domain.
+ *
+ * @param domain domain data to AND with the current query filter
+ * @returns {openerp.web.Query}
+ */
+ filter: function (domain) {
+ if (!domain) { return this; }
+ return this.clone({filter: domain});
+ },
+ /**
+ * Creates a new query with the provided parameter lazy replacing the current
+ * query's own.
+ *
+ * @param {Boolean} lazy indicates if the read_group should return only the
+ * first level of groupby records, or should return the records grouped by
+ * all levels at once (so, it makes only 1 db request).
+ * @returns {openerp.web.Query}
+ */
+ lazy: function (lazy) {
+ return this.clone({lazy: lazy});
+ },
+ /**
+ * Creates a new query with the provided limit replacing the current
+ * query's own limit
+ *
+ * @param {Number} limit maximum number of records the query should retrieve
+ * @returns {openerp.web.Query}
+ */
+ limit: function (limit) {
+ return this.clone({limit: limit});
+ },
+ /**
+ * Creates a new query with the provided offset replacing the current
+ * query's own offset
+ *
+ * @param {Number} offset number of records the query should skip before starting its retrieval
+ * @returns {openerp.web.Query}
+ */
+ offset: function (offset) {
+ return this.clone({offset: offset});
+ },
+ /**
+ * Creates a new query with the provided ordering parameters replacing
+ * those of the current query
+ *
+ * @param {String...} fields ordering clauses
+ * @returns {openerp.web.Query}
+ */
+ order_by: function (fields) {
+ if (fields === undefined) { return this; }
+ if (!(fields instanceof Array)) {
+ fields = _.toArray(arguments);
+ }
+ if (_.isEmpty(fields)) { return this; }
+ return this.clone({order_by: fields});
+ }
+});
+
+var QueryGroup = Class.extend({
+ init: function (model, grouping_fields, read_group_group) {
+ // In cases where group_by_no_leaf and no group_by, the result of
+ // read_group has aggregate fields but no __context or __domain.
+ // Create default (empty) values for those so that things don't break
+ var fixed_group = _.extend(
+ {__context: {group_by: []}, __domain: []},
+ read_group_group);
+
+ var count_key = (grouping_fields[0] && grouping_fields[0].split(':')[0]) + '_count';
+ var aggregates = {};
+ _(fixed_group).each(function (value, key) {
+ if (key.indexOf('__') === 0
+ || _.contains(grouping_fields, key)
+ || (key === count_key)) {
+ return;
+ }
+ aggregates[key] = value || 0;
+ });
+
+ this.model = new Model(
+ model, fixed_group.__context, fixed_group.__domain);
+
+ var group_size = fixed_group[count_key] || fixed_group.__count || 0;
+ var leaf_group = fixed_group.__context.group_by.length === 0;
+
+ var value = (grouping_fields.length === 1)
+ ? fixed_group[grouping_fields[0]]
+ : _.map(grouping_fields, function (field) { return fixed_group[field]; });
+ var grouped_on = (grouping_fields.length === 1)
+ ? grouping_fields[0]
+ : grouping_fields;
+ this.attributes = {
+ folded: !!(fixed_group.__fold),
+ grouped_on: grouped_on,
+ // if terminal group (or no group) and group_by_no_leaf => use group.__count
+ length: group_size,
+ value: value,
+ // A group is open-able if it's not a leaf in group_by_no_leaf mode
+ has_children: !(leaf_group && fixed_group.__context.group_by_no_leaf),
+
+ aggregates: aggregates
+ };
+ },
+ get: function (key) {
+ return this.attributes[key];
+ },
+ subgroups: function () {
+ return this.model.query().group_by(this.model.context().group_by);
+ },
+ query: function () {
+ return this.model.query.apply(this.model, arguments);
+ }
+});
+
+var DataSet = Class.extend(mixins.PropertiesMixin, {
+ /**
+ * Collection of OpenERP records, used to share records and the current selection between views.
+ *
+ * @constructs instance.web.DataSet
+ *
+ * @param {String} model the OpenERP model this dataset will manage
+ */
+ init: function(parent, model, context) {
+ mixins.PropertiesMixin.init.call(this);
+ this.model = model;
+ this.context = context || {};
+ this.index = null;
+ this._sort = [];
+ this._model = new Model(model, context);
+ },
+ previous: function () {
+ this.index -= 1;
+ if (!this.ids.length) {
+ this.index = null;
+ } else if (this.index < 0) {
+ this.index = this.ids.length - 1;
+ }
+ return this;
+ },
+ next: function () {
+ this.index += 1;
+ if (!this.ids.length) {
+ this.index = null;
+ } else if (this.index >= this.ids.length) {
+ this.index = 0;
+ }
+ return this;
+ },
+ select_id: function(id) {
+ var idx = this.get_id_index(id);
+ if (idx === null) {
+ return false;
+ } else {
+ this.index = idx;
+ return true;
+ }
+ },
+ get_id_index: function(id) {
+ for (var i=0, ii=this.ids.length; i<ii; i++) {
+ // Here we use type coercion because of the mess potentially caused by
+ // OpenERP ids fetched from the DOM as string. (eg: dhtmlxcalendar)
+ // OpenERP ids can be non-numeric too ! (eg: recursive events in calendar)
+ if (id == this.ids[i]) {
+ return i;
+ }
+ }
+ return null;
+ },
+ /**
+ * Read records.
+ *
+ * @param {Array} ids identifiers of the records to read
+ * @param {Array} [fields] fields to read and return, by default all fields are returned
+ * @param {Object} [options]
+ * @returns {$.Deferred}
+ */
+ read_ids: function (ids, fields, options) {
+ if (_.isEmpty(ids))
+ return $.Deferred().resolve([]);
+
+ options = options || {};
+ var method = 'read';
+ var ids_arg = ids;
+ var context = this.get_context(options.context);
+ if (options.check_access_rule === true){
+ method = 'search_read';
+ ids_arg = [['id', 'in', ids]];
+ context = new CompoundContext(context, {active_test: false});
+ }
+ return this._model.call(method,
+ [ids_arg, fields || false],
+ {context: context})
+ .then(function (records) {
+ if (records.length <= 1) { return records; }
+ var indexes = {};
+ for (var i = 0; i < ids.length; i++) {
+ indexes[ids[i]] = i;
+ }
+ records.sort(function (a, b) {
+ return indexes[a.id] - indexes[b.id];
+ });
+ return records;
+ });
+ },
+ /**
+ * Read a slice of the records represented by this DataSet, based on its
+ * domain and context.
+ *
+ * @param {Array} [fields] fields to read and return, by default all fields are returned
+ * @params {Object} [options]
+ * @param {Number} [options.offset=0] The index from which selected records should be returned
+ * @param {Number} [options.limit=null] The maximum number of records to return
+ * @returns {$.Deferred}
+ */
+ read_slice: function (fields, options) {
+ var self = this;
+ options = options || {};
+ return this._model.query(fields)
+ .limit(options.limit || false)
+ .offset(options.offset || 0)
+ .all().done(function (records) {
+ self.ids = _(records).pluck('id');
+ });
+ },
+ /**
+ * Reads the current dataset record (from its index)
+ *
+ * @params {Array} [fields] fields to read and return, by default all fields are returned
+ * @param {Object} [options.context] context data to add to the request payload, on top of the DataSet's own context
+ * @returns {$.Deferred}
+ */
+ read_index: function (fields, options) {
+ options = options || {};
+ return this.read_ids([this.ids[this.index]], fields, options).then(function (records) {
+ if (_.isEmpty(records)) { return $.Deferred().reject().promise(); }
+ return records[0];
+ });
+ },
+ /**
+ * Reads default values for the current model
+ *
+ * @param {Array} [fields] fields to get default values for, by default all defaults are read
+ * @param {Object} [options.context] context data to add to the request payload, on top of the DataSet's own context
+ * @returns {$.Deferred}
+ */
+ default_get: function(fields, options) {
+ options = options || {};
+ return this._model.call('default_get',
+ [fields], {context: this.get_context(options.context)});
+ },
+ /**
+ * Creates a new record in db
+ *
+ * @param {Object} data field values to set on the new record
+ * @param {Object} options Dictionary that can contain the following keys:
+ * - readonly_fields: Values from readonly fields that were updated by
+ * on_changes. Only used by the BufferedDataSet to make the o2m work correctly.
+ * @returns {$.Deferred}
+ */
+ create: function(data, options) {
+ var self = this;
+ return this._model.call('create', [data], {
+ context: this.get_context()
+ }).done(function () {
+ self.trigger('dataset_changed', data, options);
+ });
+ },
+ /**
+ * Saves the provided data in an existing db record
+ *
+ * @param {Number|String} id identifier for the record to alter
+ * @param {Object} data field values to write into the record
+ * @param {Object} options Dictionary that can contain the following keys:
+ * - context: The context to use in the server-side call.
+ * - readonly_fields: Values from readonly fields that were updated by
+ * on_changes. Only used by the BufferedDataSet to make the o2m work correctly.
+ * @returns {$.Deferred}
+ */
+ write: function (id, data, options) {
+ options = options || {};
+ var self = this;
+ return this._model.call('write', [[id], data], {
+ context: this.get_context(options.context)
+ }).done(function () {
+ self.trigger('dataset_changed', id, data, options);
+ });
+ },
+ /**
+ * Deletes an existing record from the database
+ *
+ * @param {Number|String} ids identifier of the record to delete
+ */
+ unlink: function(ids) {
+ var self = this;
+ return this._model.call('unlink', [ids], {
+ context: this.get_context()
+ }).done(function () {
+ self.trigger('dataset_changed', ids);
+ });
+ },
+ /**
+ * Calls an arbitrary RPC method
+ *
+ * @param {String} method name of the method (on the current model) to call
+ * @param {Array} [args] arguments to pass to the method
+ * @param {Function} callback
+ * @param {Function} error_callback
+ * @returns {$.Deferred}
+ */
+ call: function (method, args) {
+ return this._model.call(method, args);
+ },
+ /**
+ * Calls a button method, usually returning some sort of action
+ *
+ * @param {String} method
+ * @param {Array} [args]
+ * @returns {$.Deferred}
+ */
+ call_button: function (method, args) {
+ return this._model.call_button(method, args);
+ },
+ /**
+ * Fetches the "readable name" for records, based on intrinsic rules
+ *
+ * @param {Array} ids
+ * @returns {$.Deferred}
+ */
+ name_get: function(ids) {
+ return this._model.call('name_get', [ids], {context: this.get_context()});
+ },
+ /**
+ *
+ * @param {String} name name to perform a search for/on
+ * @param {Array} [domain=[]] filters for the objects returned, OpenERP domain
+ * @param {String} [operator='ilike'] matching operator to use with the provided name value
+ * @param {Number} [limit=0] maximum number of matches to return
+ * @param {Function} callback function to call with name_search result
+ * @returns {$.Deferred}
+ */
+ name_search: function (name, domain, operator, limit) {
+ return this._model.call('name_search', {
+ name: name || '',
+ args: domain || false,
+ operator: operator || 'ilike',
+ context: this._model.context(),
+ limit: limit || 0
+ });
+ },
+ /**
+ * @param name
+ */
+ name_create: function(name) {
+ return this._model.call('name_create', [name], {context: this.get_context()});
+ },
+ exec_workflow: function (id, signal) {
+ return this._model.exec_workflow(id, signal);
+ },
+ get_context: function(request_context) {
+ return this._model.context(request_context);
+ },
+ /**
+ * Reads or changes sort criteria on the dataset.
+ *
+ * If not provided with any argument, serializes the sort criteria to
+ * an SQL-like form usable by OpenERP's ORM.
+ *
+ * If given a field, will set that field as first sorting criteria or,
+ * if the field is already the first sorting criteria, will reverse it.
+ *
+ * @param {String} [field] field to sort on, reverses it (toggle from ASC to DESC) if already the main sort criteria
+ * @param {Boolean} [force_reverse=false] forces inserting the field as DESC
+ * @returns {String|undefined}
+ */
+ sort: function (field, force_reverse) {
+ if (!field) {
+ return serialize_sort(this._sort);
+ }
+ var reverse = force_reverse || (this._sort[0] === field);
+ this._sort.splice.apply(
+ this._sort, [0, this._sort.length].concat(
+ _.without(this._sort, field, '-' + field)));
+
+ this._sort.unshift((reverse ? '-' : '') + field);
+ return undefined;
+ },
+ /**
+ * Set the sort criteria on the dataset.
+ *
+ * @param {Array} fields_list: list of fields order descriptors, as used by
+ * Odoo's ORM (such as 'name desc', 'product_id', 'order_date asc')
+ */
+ set_sort: function (fields_list) {
+ this._sort = deserialize_sort(fields_list);
+ },
+ size: function () {
+ return this.ids.length;
+ },
+ alter_ids: function(n_ids) {
+ this.ids = n_ids;
+ },
+ remove_ids: function (ids) {
+ this.alter_ids(_(this.ids).difference(ids));
+ },
+ add_ids: function(ids, at) {
+ var args = [at, 0].concat(_.difference(ids, this.ids));
+ this.ids.splice.apply(this.ids, args);
+ },
+ /**
+ * Resequence records.
+ *
+ * @param {Array} ids identifiers of the records to resequence
+ * @returns {$.Deferred}
+ */
+ resequence: function (ids, options) {
+ options = options || {};
+ return session.rpc('/web/dataset/resequence', {
+ model: this.model,
+ ids: ids,
+ context: pyeval.eval(
+ 'context', this.get_context(options.context)),
+ }).then(function (results) {
+ return results;
+ });
+ },
+});
+
+var DataSetStatic = DataSet.extend({
+ init: function(parent, model, context, ids) {
+ this._super(parent, model, context);
+ // all local records
+ this.ids = ids || [];
+ },
+ read_slice: function (fields, options) {
+ options = options || {};
+ fields = fields || {};
+ var offset = options.offset || 0,
+ limit = options.limit || false;
+ var end_pos = limit && limit !== -1 ? offset + limit : this.ids.length;
+ return this.read_ids(this.ids.slice(offset, end_pos), fields);
+ },
+ set_ids: function (ids) {
+ this.ids = ids;
+ if (ids.length === 0) {
+ this.index = null;
+ } else if (this.index >= ids.length - 1) {
+ this.index = ids.length - 1;
+ }
+ },
+ unlink: function(ids) {
+ this.set_ids(_.without.apply(null, [this.ids].concat(ids)));
+ this.trigger('unlink', ids);
+ return $.Deferred().resolve({result: true});
+ },
+});
+
+var DataSetSearch = DataSet.extend({
+ /**
+ * @constructs instance.web.DataSetSearch
+ * @extends instance.web.DataSet
+ *
+ * @param {Object} parent
+ * @param {String} model
+ * @param {Object} context
+ * @param {Array} domain
+ */
+ init: function(parent, model, context, domain) {
+ this._super(parent, model, context);
+ this.domain = domain || [];
+ this._length = null;
+ this.ids = [];
+ this._model = new Model(model, context, domain);
+ },
+ /**
+ * Read a slice of the records represented by this DataSet, based on its
+ * domain and context.
+ *
+ * @params {Object} options
+ * @param {Array} [options.fields] fields to read and return, by default all fields are returned
+ * @param {Object} [options.context] context data to add to the request payload, on top of the DataSet's own context
+ * @param {Array} [options.domain] domain data to add to the request payload, ANDed with the dataset's domain
+ * @param {Number} [options.offset=0] The index from which selected records should be returned
+ * @param {Number} [options.limit=null] The maximum number of records to return
+ * @returns {$.Deferred}
+ */
+ read_slice: function (fields, options) {
+ options = options || {};
+ var self = this;
+ var q = this._model.query(fields || false)
+ .filter(options.domain)
+ .context(options.context)
+ .offset(options.offset || 0)
+ .limit(options.limit || false);
+ q = q.order_by.apply(q, this._sort);
+
+ return q.all().done(function (records) {
+ // FIXME: not sure about that one, *could* have discarded count
+ q.count().done(function (count) { self._length = count; });
+ self.ids = _(records).pluck('id');
+ });
+ },
+ get_domain: function (other_domain) {
+ return this._model.domain(other_domain);
+ },
+ alter_ids: function (ids) {
+ this._super(ids);
+ if (this.index !== null && this.index >= this.ids.length) {
+ this.index = this.ids.length > 0 ? this.ids.length - 1 : 0;
+ }
+ },
+ remove_ids: function (ids) {
+ var before = this.ids.length;
+ this._super(ids);
+ if (this._length) {
+ this._length -= (before - this.ids.length);
+ }
+ },
+ unlink: function(ids, callback, error_callback) {
+ var self = this;
+ return this._super(ids).done(function() {
+ self.remove_ids( ids);
+ self.trigger("dataset_changed", ids, callback, error_callback);
+ });
+ },
+ size: function () {
+ if (this._length !== null) {
+ return this._length;
+ }
+ return this._super();
+ }
+});
+
+var BufferedDataSet = DataSetStatic.extend({
+ virtual_id_prefix: "one2many_v_id_",
+ debug_mode: true,
+ init: function() {
+ this._super.apply(this, arguments);
+ this.reset_ids([]);
+ this.last_default_get = {};
+ this.running_reads = [];
+ },
+ default_get: function(fields, options) {
+ var self = this;
+ return this._super(fields, options).done(function(res) {
+ self.last_default_get = res;
+ });
+ },
+ create: function(data, options) {
+ var cached = {
+ id:_.uniqueId(this.virtual_id_prefix),
+ values: _.extend({}, data, (options || {}).readonly_fields || {}),
+ defaults: this.last_default_get
+ };
+ this.to_create.push(_.extend(_.clone(cached), {values: _.clone(data)}));
+ this.cache.push(cached);
+ return $.Deferred().resolve(cached.id).promise();
+ },
+ write: function (id, data, options) {
+ var self = this;
+ var record = _.detect(this.to_create, function(x) {return x.id === id;});
+ record = record || _.detect(this.to_write, function(x) {return x.id === id;});
+ var dirty = false;
+ if (record) {
+ for (var k in data) {
+ if (record.values[k] === undefined || record.values[k] !== data[k]) {
+ dirty = true;
+ break;
+ }
+ }
+ $.extend(record.values, data);
+ } else {
+ dirty = true;
+ record = {id: id, values: data};
+ self.to_write.push(record);
+ }
+ var cached = _.detect(this.cache, function(x) {return x.id === id;});
+ if (!cached) {
+ cached = {id: id, values: {}};
+ this.cache.push(cached);
+ }
+ $.extend(cached.values, _.extend({}, record.values, (options || {}).readonly_fields || {}));
+ if (dirty)
+ this.trigger("dataset_changed", id, data, options);
+ return $.Deferred().resolve(true).promise();
+ },
+ unlink: function(ids, callback, error_callback) {
+ var self = this;
+ _.each(ids, function(id) {
+ if (! _.detect(self.to_create, function(x) { return x.id === id; })) {
+ self.to_delete.push({id: id});
+ }
+ });
+ this.to_create = _.reject(this.to_create, function(x) { return _.include(ids, x.id);});
+ this.to_write = _.reject(this.to_write, function(x) { return _.include(ids, x.id);});
+ this.cache = _.reject(this.cache, function(x) { return _.include(ids, x.id);});
+ this.set_ids(_.without.apply(_, [this.ids].concat(ids)));
+ this.trigger("dataset_changed", ids, callback, error_callback);
+ return $.async_when({result: true}).done(callback);
+ },
+ reset_ids: function(ids) {
+ this.set_ids(ids);
+ this.to_delete = [];
+ this.to_create = [];
+ this.to_write = [];
+ this.cache = [];
+ this.delete_all = false;
+ _.each(_.clone(this.running_reads), function(el) {
+ el.reject();
+ });
+ },
+ read_ids: function (ids, fields, options) {
+ var self = this;
+ var to_get = [];
+ _.each(ids, function(id) {
+ var cached = _.detect(self.cache, function(x) {return x.id === id;});
+ var created = _.detect(self.to_create, function(x) {return x.id === id;});
+ if (created) {
+ _.each(fields, function(x) {if (cached.values[x] === undefined)
+ cached.values[x] = created.defaults[x] || false;});
+ } else {
+ if (!cached || !_.all(fields, function(x) {return cached.values[x] !== undefined;}))
+ to_get.push(id);
+ }
+ });
+ var return_records = function() {
+ var records = _.map(ids, function(id) {
+ var c = _.find(self.cache, function(c) {return c.id === id;});
+ return _.isUndefined(c) ? c : _.extend({}, c.values, {"id": id});
+ });
+ if (self.debug_mode) {
+ if (_.include(records, undefined)) {
+ throw "Record not correctly loaded";
+ }
+ }
+ var sort_fields = self._sort,
+ compare = function (v1, v2) {
+ return (v1 < v2) ? -1
+ : (v1 > v2) ? 1
+ : 0;
+ };
+ // Array.sort is not necessarily stable. We must be careful with this because
+ // sorting an array where all items are considered equal is a worst-case that
+ // will randomize the array with an unstable sort! Therefore we must avoid
+ // sorting if there are no sort_fields (i.e. all items are considered equal)
+ // See also: http://ecma262-5.com/ELS5_Section_15.htm#Section_15.4.4.11
+ // http://code.google.com/p/v8/issues/detail?id=90
+ if (sort_fields.length) {
+ records.sort(function (a, b) {
+ return _.reduce(sort_fields, function (acc, field) {
+ if (acc) { return acc; }
+ var sign = 1;
+ if (field[0] === '-') {
+ sign = -1;
+ field = field.slice(1);
+ }
+ if(!a[field] && a[field] !== 0){ return sign; }
+ if(!b[field] && b[field] !== 0){ return (sign == -1) ? 1 : -1; }
+ //m2o should be searched based on value[1] not based whole value(i.e. [id, value])
+ if(_.isArray(a[field]) && a[field].length == 2 && _.isString(a[field][1])){
+ return sign * compare(a[field][1], b[field][1]);
+ }
+ return sign * compare(a[field], b[field]);
+ }, 0);
+ });
+ }
+ return $.when(records);
+ };
+ if(to_get.length > 0) {
+ var def = $.Deferred();
+ self.running_reads.push(def);
+ def.always(function() {
+ self.running_reads = _.without(self.running_reads, def);
+ });
+ this._super(to_get, fields, options).then(function() {
+ def.resolve.apply(def, arguments);
+ }, function() {
+ def.reject.apply(def, arguments);
+ });
+ return def.then(function(records) {
+ _.each(records, function(record, index) {
+ var id = to_get[index];
+ var cached = _.detect(self.cache, function(x) {return x.id === id;});
+ if (!cached) {
+ self.cache.push({id: id, values: record});
+ } else {
+ // I assume cache value is prioritary
+ cached.values = _.defaults(_.clone(cached.values), record);
+ }
+ });
+ return return_records();
+ });
+ } else {
+ return return_records();
+ }
+ },
+ /**
+ * Invalidates caching of a record in the dataset to ensure the next read
+ * of that record will hit the server.
+ *
+ * Of use when an action is going to remote-alter a record which will then
+ * need to be reloaded, e.g. action button.
+ *
+ * @param {Object} id record to remove from the BDS's cache
+ */
+ evict_record: function (id) {
+ // Don't evict records which haven't yet been saved: there is no more
+ // recent data on the server (and there potentially isn't any data),
+ // and this breaks the assumptions of other methods (that the data
+ // for new and altered records is both in the cache and in the to_write
+ // or to_create collection)
+ if (_(this.to_create.concat(this.to_write)).find(function (record) {
+ return record.id === id; })) {
+ return;
+ }
+ for(var i=0, len=this.cache.length; i<len; ++i) {
+ var record = this.cache[i];
+ // if record we call the button upon is in the cache
+ if (record.id === id) {
+ // evict it so it gets reloaded from server
+ this.cache.splice(i, 1);
+ break;
+ }
+ }
+ },
+ call_button: function (method, args) {
+ this.evict_record(args[0][0]);
+ return this._super(method, args);
+ },
+ exec_workflow: function (id, signal) {
+ this.evict_record(id);
+ return this._super(id, signal);
+ },
+ alter_ids: function(n_ids) {
+ this._super(n_ids);
+ this.trigger("dataset_changed", n_ids);
+ },
+});
+
+BufferedDataSet.virtual_id_regex = /^one2many_v_id_.*$/;
+
+var ProxyDataSet = DataSetSearch.extend({
+ init: function() {
+ this._super.apply(this, arguments);
+ this.create_function = null;
+ this.write_function = null;
+ this.read_function = null;
+ this.default_get_function = null;
+ this.unlink_function = null;
+ },
+ read_ids: function (ids, fields, options) {
+ if (this.read_function) {
+ return this.read_function(ids, fields, options, this._super);
+ } else {
+ return this._super.apply(this, arguments);
+ }
+ },
+ default_get: function(fields, options) {
+ if (this.default_get_function) {
+ return this.default_get_function(fields, options, this._super);
+ } else {
+ return this._super.apply(this, arguments);
+ }
+ },
+ create: function(data, options) {
+ if (this.create_function) {
+ return this.create_function(data, options, this._super);
+ } else {
+ return this._super.apply(this, arguments);
+ }
+ },
+ write: function (id, data, options) {
+ if (this.write_function) {
+ return this.write_function(id, data, options, this._super);
+ } else {
+ return this._super.apply(this, arguments);
+ }
+ },
+ unlink: function(ids) {
+ if (this.unlink_function) {
+ return this.unlink_function(ids, this._super);
+ } else {
+ return this._super.apply(this, arguments);
+ }
+ },
+});
+
+var CompoundContext = Class.extend({
+ init: function () {
+ this.__ref = "compound_context";
+ this.__contexts = [];
+ this.__eval_context = null;
+ var self = this;
+ _.each(arguments, function(x) {
+ self.add(x);
+ });
+ },
+ add: function (context) {
+ this.__contexts.push(context);
+ return this;
+ },
+ set_eval_context: function (eval_context) {
+ this.__eval_context = eval_context;
+ return this;
+ },
+ get_eval_context: function () {
+ return this.__eval_context;
+ },
+ eval: function() {
+ return pyeval.eval('context', this, undefined, {no_user_context: true});
+ },
+});
+
+var CompoundDomain = Class.extend({
+ init: function () {
+ this.__ref = "compound_domain";
+ this.__domains = [];
+ this.__eval_context = null;
+ var self = this;
+ _.each(arguments, function(x) {
+ self.add(x);
+ });
+ },
+ add: function(domain) {
+ this.__domains.push(domain);
+ return this;
+ },
+ set_eval_context: function(eval_context) {
+ this.__eval_context = eval_context;
+ return this;
+ },
+ get_eval_context: function() {
+ return this.__eval_context;
+ },
+ eval: function() {
+ return pyeval.eval('domain', this);
+ },
+});
+
+function compute_domain (expr, fields) {
+ if (! (expr instanceof Array))
+ return !! expr;
+ var stack = [];
+ for (var i = expr.length - 1; i >= 0; i--) {
+ var ex = expr[i];
+ if (ex.length == 1) {
+ var top = stack.pop();
+ switch (ex) {
+ case '|':
+ stack.push(stack.pop() || top);
+ continue;
+ case '&':
+ stack.push(stack.pop() && top);
+ continue;
+ case '!':
+ stack.push(!top);
+ continue;
+ default:
+ throw new Error(_.str.sprintf(
+ _t("Unknown operator %s in domain %s"),
+ ex, JSON.stringify(expr)));
+ }
+ }
+
+ var field = fields[ex[0]];
+ if (!field) {
+ throw new Error(_.str.sprintf(
+ _t("Unknown field %s in domain %s"),
+ ex[0], JSON.stringify(expr)));
+ }
+ var field_value = field.get_value ? field.get_value() : field.value;
+ var op = ex[1];
+ var val = ex[2];
+
+ switch (op.toLowerCase()) {
+ case '=':
+ case '==':
+ stack.push(_.isEqual(field_value, val));
+ break;
+ case '!=':
+ case '<>':
+ stack.push(!_.isEqual(field_value, val));
+ break;
+ case '<':
+ stack.push(field_value < val);
+ break;
+ case '>':
+ stack.push(field_value > val);
+ break;
+ case '<=':
+ stack.push(field_value <= val);
+ break;
+ case '>=':
+ stack.push(field_value >= val);
+ break;
+ case 'in':
+ if (!_.isArray(val)) val = [val];
+ stack.push(_(val).contains(field_value));
+ break;
+ case 'not in':
+ if (!_.isArray(val)) val = [val];
+ stack.push(!_(val).contains(field_value));
+ break;
+ default:
+ console.warn(
+ _t("Unsupported operator %s in domain %s"),
+ op, JSON.stringify(expr));
+ }
+ }
+ return _.all(stack, _.identity);
+}
+
+
+return {
+ Query: Query,
+ DataSet: DataSet,
+ DataSetStatic: DataSetStatic,
+ DataSetSearch: DataSetSearch,
+ BufferedDataSet: BufferedDataSet,
+ ProxyDataSet: ProxyDataSet,
+ CompoundContext: CompoundContext,
+ CompoundDomain: CompoundDomain,
+ compute_domain: compute_domain,
+};
+
+}); \ No newline at end of file
diff --git a/addons/web/static/src/js/framework/dialog.js b/addons/web/static/src/js/framework/dialog.js
new file mode 100644
index 00000000000..9464c452124
--- /dev/null
+++ b/addons/web/static/src/js/framework/dialog.js
@@ -0,0 +1,194 @@
+odoo.define('web.Dialog', ['web.core', 'web.Widget'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+var opened_modals = [];
+
+/**
+ A useful class to handle dialogs.
+
+ Attributes:
+ - $buttons: A jQuery element targeting a dom part where buttons can be added. It always exists
+ during the lifecycle of the dialog.
+*/
+var Dialog = Widget.extend({
+ dialog_title: "",
+ /**
+ Constructor.
+
+ @param {Widget} parent
+ @param {dictionary} options A dictionary that will be forwarded to jQueryUI Dialog. Additionaly, that
+ dictionary can contain the following keys:
+ - size: one of the following: 'large', 'medium', 'small'
+ - dialogClass: class to add to the body of dialog
+ - buttons: Deprecated. The buttons key is not propagated to jQueryUI Dialog. It must be a dictionary (key = button
+ label, value = click handler) or a list of dictionaries (each element in the dictionary is send to the
+ corresponding method of a jQuery element targeting the <button> tag). It is deprecated because all dialogs
+ in OpenERP must be personalized in some way (button in red, link instead of button, ...) and this
+ feature does not allow that kind of personalization.
+ - destroy_on_close: Default true. If true and the dialog is closed, it is automatically destroyed.
+ @param {jQuery object} content Some content to replace this.$el .
+ */
+ init: function (parent, options, content) {
+ this._super(parent);
+ this.content_to_set = content;
+ this.dialog_options = {
+ destroy_on_close: true,
+ size: 'large', //'medium', 'small'
+ buttons: null,
+ };
+ if (options) {
+ _.extend(this.dialog_options, options);
+ }
+ this.on("closing", this, this._closing);
+ this.$buttons = $('<div class="modal-footer"><span class="oe_dialog_custom_buttons"/></div>');
+ },
+ renderElement: function() {
+ if (this.content_to_set) {
+ this.setElement(this.content_to_set);
+ } else if (this.template) {
+ this._super();
+ }
+ },
+ /**
+ Opens the popup. Inits the dialog if it is not already inited.
+
+ @return this
+ */
+ open: function() {
+ if (!this.dialog_inited) {
+ this.init_dialog();
+ }
+ this.$buttons.insertAfter(this.$dialog_box.find(".modal-body"));
+ $('.tooltip').remove(); //remove open tooltip if any to prevent them staying when modal is opened
+ //add to list of currently opened modal
+ opened_modals.push(this.$dialog_box);
+ return this;
+ },
+ _add_buttons: function(buttons) {
+ var self = this;
+ var $customButons = this.$buttons.find('.oe_dialog_custom_buttons').empty();
+ _.each(buttons, function(fn, text) {
+ // buttons can be object or array
+ var pre_text = fn.pre_text || "";
+ var post_text = fn.post_text || "";
+ var oe_link_class = fn.oe_link_class;
+ if (!_.isFunction(fn)) {
+ text = fn.text;
+ fn = fn.click;
+ }
+ var $but = $(QWeb.render('WidgetButton', { widget : { pre_text: pre_text, post_text: post_text, string: text, node: { attrs: {'class': oe_link_class} }}}));
+ $customButons.append($but);
+ $but.filter('button').on('click', function(ev) {
+ fn.call(self.$el, ev);
+ });
+ });
+ },
+ /**
+ Initializes the popup.
+
+ @return The result returned by start().
+ */
+ init_dialog: function() {
+ var self = this;
+ var options = _.extend({}, this.dialog_options);
+ options.title = options.title || this.dialog_title;
+ if (options.buttons) {
+ this._add_buttons(options.buttons);
+ delete(options.buttons);
+ }
+ this.renderElement();
+ this.$dialog_box = $(QWeb.render('Dialog', options)).appendTo("body");
+ this.$el.modal({
+ 'backdrop': false,
+ 'keyboard': true,
+ });
+ if (options.size !== 'large'){
+ var dialog_class_size = this.$dialog_box.find('.modal-lg').removeClass('modal-lg');
+ if (options.size === 'small'){
+ dialog_class_size.addClass('modal-sm');
+ }
+ }
+
+ this.$el.appendTo(this.$dialog_box.find(".modal-body"));
+ var $dialog_content = this.$dialog_box.find('.modal-content');
+ if (options.dialogClass){
+ $dialog_content.find(".modal-body").addClass(options.dialogClass);
+ }
+ $dialog_content.openerpClass();
+
+ this.$dialog_box.on('hidden.bs.modal', this, function() {
+ self.close();
+ });
+ this.$dialog_box.modal('show');
+
+ this.dialog_inited = true;
+ var res = this.start();
+ return res;
+ },
+ /**
+ Closes (hide) the popup, if destroy_on_close was passed to the constructor, it will be destroyed instead.
+ */
+ close: function(reason) {
+ if (this.dialog_inited && !this.__tmp_dialog_hiding) {
+ $('.tooltip').remove(); //remove open tooltip if any to prevent them staying when modal has disappeared
+ if (this.$el.is(":data(bs.modal)")) { // may have been destroyed by closing signal
+ this.__tmp_dialog_hiding = true;
+ this.$dialog_box.modal('hide');
+ this.__tmp_dialog_hiding = undefined;
+ }
+ this.trigger("closing", reason);
+ }
+ },
+ _closing: function() {
+ if (this.__tmp_dialog_destroying)
+ return;
+ if (this.dialog_options.destroy_on_close) {
+ this.__tmp_dialog_closing = true;
+ this.destroy();
+ this.__tmp_dialog_closing = undefined;
+ }
+ },
+ /**
+ Destroys the popup, also closes it.
+ */
+ destroy: function (reason) {
+ this.$buttons.remove();
+ _.each(this.getChildren(), function(el) {
+ el.destroy();
+ });
+ if (! this.__tmp_dialog_closing) {
+ this.__tmp_dialog_destroying = true;
+ this.close(reason);
+ this.__tmp_dialog_destroying = undefined;
+ }
+ if (this.dialog_inited && !this.isDestroyed() && this.$el.is(":data(bs.modal)")) {
+ //we need this to put the instruction to remove modal from DOM at the end
+ //of the queue, otherwise it might already have been removed before the modal-backdrop
+ //is removed when pressing escape key
+ var $element = this.$dialog_box;
+ setTimeout(function () {
+ //remove modal from list of opened modal since we just destroy it
+ var modal_list_index = $.inArray($element, opened_modals);
+ if (modal_list_index > -1){
+ opened_modals.splice(modal_list_index,1)[0].remove();
+ }
+ if (opened_modals.length > 0){
+ //we still have other opened modal so we should focus it
+ opened_modals[opened_modals.length-1].focus();
+ //keep class modal-open (deleted by bootstrap hide fnct) on body
+ //to allow scrolling inside the modal
+ $('body').addClass('modal-open');
+ }
+ },0);
+ }
+ this._super();
+ }
+});
+
+return Dialog;
+
+});
diff --git a/addons/web/static/src/js/framework/formats.js b/addons/web/static/src/js/framework/formats.js
new file mode 100644
index 00000000000..4126447c76a
--- /dev/null
+++ b/addons/web/static/src/js/framework/formats.js
@@ -0,0 +1,189 @@
+odoo.define('web.formats', ['web.core', 'web.time', 'web.utils'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var time = require('web.time');
+var utils = require('web.utils');
+
+var _t = core._t;
+
+
+/**
+ * Formats a single atomic value based on a field descriptor
+ *
+ * @param {Object} value read from OpenERP
+ * @param {Object} descriptor union of orm field and view field
+ * @param {Object} [descriptor.widget] widget to use to display the value
+ * @param {Object} descriptor.type fallback if no widget is provided, or if the provided widget is unknown
+ * @param {Object} [descriptor.digits] used for the formatting of floats
+ * @param {String} [value_if_empty=''] returned if the ``value`` argument is considered empty
+ */
+function format_value (value, descriptor, value_if_empty) {
+ var l10n = _t.database.parameters;
+ var date_format = time.strftime_to_moment_format(l10n.date_format);
+ var time_format = time.strftime_to_moment_format(l10n.time_format);
+ // If NaN value, display as with a `false` (empty cell)
+ if (typeof value === 'number' && isNaN(value)) {
+ value = false;
+ }
+ //noinspection FallthroughInSwitchStatementJS
+ switch (value) {
+ case '':
+ if (descriptor.type === 'char' || descriptor.type === 'text') {
+ return '';
+ }
+ console.warn('Field', descriptor, 'had an empty string as value, treating as false...');
+ return value_if_empty === undefined ? '' : value_if_empty;
+ case false:
+ case undefined:
+ case Infinity:
+ case -Infinity:
+ return value_if_empty === undefined ? '' : value_if_empty;
+ }
+ switch (descriptor.widget || descriptor.type || (descriptor.field && descriptor.field.type)) {
+ case 'id':
+ return value.toString();
+ case 'integer':
+ return utils.insert_thousand_seps(
+ _.str.sprintf('%d', value));
+ case 'float':
+ var digits = descriptor.digits ? descriptor.digits : [69,2];
+ digits = typeof digits === "string" ? py.eval(digits) : digits;
+ var precision = digits[1];
+ var formatted = _.str.sprintf('%.' + precision + 'f', value).split('.');
+ formatted[0] = utils.insert_thousand_seps(formatted[0]);
+ return formatted.join(l10n.decimal_point);
+ case 'float_time':
+ var pattern = '%02d:%02d';
+ if (value < 0) {
+ value = Math.abs(value);
+ pattern = '-' + pattern;
+ }
+ var hour = Math.floor(value);
+ var min = Math.round((value % 1) * 60);
+ if (min == 60){
+ min = 0;
+ hour = hour + 1;
+ }
+ return _.str.sprintf(pattern, hour, min);
+ case 'many2one':
+ // name_get value format
+ return value[1] ? value[1].split("\n")[0] : value[1];
+ case 'one2many':
+ case 'many2many':
+ if (typeof value === 'string') {
+ return value;
+ }
+ return _.str.sprintf(_t("(%d records)"), value.length);
+ case 'datetime':
+ if (typeof(value) == "string")
+ value = moment(time.auto_str_to_date(value));
+ else {
+ value = moment(value);
+ }
+ return value.format(date_format + ' ' + time_format);
+ case 'date':
+ if (typeof(value) == "string")
+ value = moment(time.auto_str_to_date(value));
+ else {
+ value = moment(value);
+ }
+ return value.format(date_format);
+ case 'time':
+ if (typeof(value) == "string")
+ value = moment(time.auto_str_to_date(value));
+ else {
+ value = moment(value);
+ }
+ return value.format(time_format);
+ case 'selection': case 'statusbar':
+ // Each choice is [value, label]
+ if(_.isArray(value)) {
+ return value[1];
+ }
+ var result = _(descriptor.selection).detect(function (choice) {
+ return choice[0] === value;
+ });
+ if (result) { return result[1]; }
+ return;
+ default:
+ return value;
+ }
+}
+
+function parse_value (value, descriptor, value_if_empty) {
+ var date_pattern = time.strftime_to_moment_format(_t.database.parameters.date_format),
+ time_pattern = time.strftime_to_moment_format(_t.database.parameters.time_format);
+ var date_pattern_wo_zero = date_pattern.replace('MM','M').replace('DD','D'),
+ time_pattern_wo_zero = time_pattern.replace('HH','H').replace('mm','m').replace('ss','s');
+ switch (value) {
+ case false:
+ case "":
+ return value_if_empty === undefined ? false : value_if_empty;
+ }
+ var tmp;
+ switch (descriptor.widget || descriptor.type || (descriptor.field && descriptor.field.type)) {
+ case 'integer':
+ do {
+ tmp = value;
+ value = value.replace(_t.database.parameters.thousands_sep, "");
+ } while(tmp !== value);
+ tmp = Number(value);
+ // do not accept not numbers or float values
+ if (isNaN(tmp) || tmp % 1)
+ throw new Error(_.str.sprintf(_t("'%s' is not a correct integer"), value));
+ return tmp;
+ case 'float':
+ tmp = Number(value);
+ if (!isNaN(tmp))
+ return tmp;
+
+ var tmp2 = value;
+ do {
+ tmp = tmp2;
+ tmp2 = tmp.replace(_t.database.parameters.thousands_sep, "");
+ } while(tmp !== tmp2);
+ var reformatted_value = tmp.replace(_t.database.parameters.decimal_point, ".");
+ var parsed = Number(reformatted_value);
+ if (isNaN(parsed))
+ throw new Error(_.str.sprintf(_t("'%s' is not a correct float"), value));
+ return parsed;
+ case 'float_time':
+ var factor = 1;
+ if (value[0] === '-') {
+ value = value.slice(1);
+ factor = -1;
+ }
+ var float_time_pair = value.split(":");
+ if (float_time_pair.length != 2)
+ return factor * parse_value(value, {type: "float"});
+ var hours = parse_value(float_time_pair[0], {type: "integer"});
+ var minutes = parse_value(float_time_pair[1], {type: "integer"});
+ return factor * (hours + (minutes / 60));
+ case 'progressbar':
+ return parse_value(value, {type: "float"});
+ case 'datetime':
+ var datetime = moment(value, [date_pattern + ' ' + time_pattern, date_pattern_wo_zero + ' ' + time_pattern_wo_zero], true);
+ if (datetime.isValid())
+ return time.datetime_to_str(datetime.toDate());
+ throw new Error(_.str.sprintf(_t("'%s' is not a correct datetime"), value));
+ case 'date':
+ var date = moment(value, [date_pattern, date_pattern_wo_zero], true);
+ if (date.isValid())
+ return time.date_to_str(date.toDate());
+ throw new Error(_.str.sprintf(_t("'%s' is not a correct date"), value));
+ case 'time':
+ var _time = moment(value, [time_pattern, time_pattern_wo_zero], true);
+ if (_time.isValid())
+ return time.time_to_str(_time.toDate());
+ throw new Error(_.str.sprintf(_t("'%s' is not a correct time"), value));
+ }
+ return value;
+}
+
+return {
+ format_value: format_value,
+ parse_value: parse_value,
+};
+
+});
diff --git a/addons/web/static/src/js/framework/misc.js b/addons/web/static/src/js/framework/misc.js
new file mode 100644
index 00000000000..a3aaadbc46c
--- /dev/null
+++ b/addons/web/static/src/js/framework/misc.js
@@ -0,0 +1,339 @@
+odoo.define('web.framework', ['web.core', 'web.crash_manager', 'web.session', 'web.utils', 'web.Widget'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var crash_manager = require('web.crash_manager');
+var session = require('web.session');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+var Spinner = window.Spinner;
+
+var messages_by_seconds = function() {
+ return [
+ [0, _t("Loading...")],
+ [20, _t("Still loading...")],
+ [60, _t("Still loading...<br />Please be patient.")],
+ [120, _t("Don't leave yet,<br />it's still loading...")],
+ [300, _t("You may not believe it,<br />but the application is actually loading...")],
+ [420, _t("Take a minute to get a coffee,<br />because it's loading...")],
+ [3600, _t("Maybe you should consider reloading the application by pressing F5...")]
+ ];
+};
+
+var Throbber = Widget.extend({
+ template: "Throbber",
+ start: function() {
+ var opts = {
+ lines: 13, // The number of lines to draw
+ length: 7, // The length of each line
+ width: 4, // The line thickness
+ radius: 10, // The radius of the inner circle
+ rotate: 0, // The rotation offset
+ color: '#FFF', // #rgb or #rrggbb
+ speed: 1, // Rounds per second
+ trail: 60, // Afterglow percentage
+ shadow: false, // Whether to render a shadow
+ hwaccel: false, // Whether to use hardware acceleration
+ className: 'spinner', // The CSS class to assign to the spinner
+ zIndex: 2e9, // The z-index (defaults to 2000000000)
+ top: 'auto', // Top position relative to parent in px
+ left: 'auto' // Left position relative to parent in px
+ };
+ this.spin = new Spinner(opts).spin(this.$el[0]);
+ this.start_time = new Date().getTime();
+ this.act_message();
+ },
+ act_message: function() {
+ var self = this;
+ setTimeout(function() {
+ if (self.isDestroyed())
+ return;
+ var seconds = (new Date().getTime() - self.start_time) / 1000;
+ var mes;
+ _.each(messages_by_seconds(), function(el) {
+ if (seconds >= el[0])
+ mes = el[1];
+ });
+ self.$(".oe_throbber_message").html(mes);
+ self.act_message();
+ }, 1000);
+ },
+ destroy: function() {
+ if (this.spin)
+ this.spin.stop();
+ this._super();
+ },
+});
+
+
+// special tweak for the web client
+var old_async_when = $.async_when;
+$.async_when = function() {
+ if (session.synch)
+ return $.when.apply(this, arguments);
+ else
+ return old_async_when.apply(this, arguments);
+};
+
+
+/** Setup blockui */
+if ($.blockUI) {
+ $.blockUI.defaults.baseZ = 1100;
+ $.blockUI.defaults.message = '<div class="openerp oe_blockui_spin_container" style="background-color: transparent;">';
+ $.blockUI.defaults.css.border = '0';
+ $.blockUI.defaults.css["background-color"] = '';
+}
+
+
+var throbbers = [];
+
+function blockUI () {
+ var tmp = $.blockUI.apply($, arguments);
+ var throbber = new Throbber();
+ throbbers.push(throbber);
+ throbber.appendTo($(".oe_blockui_spin_container"));
+ return tmp;
+}
+
+function unblockUI () {
+ _.invoke(throbbers, 'destroy');
+ throbbers = [];
+ return $.unblockUI.apply($, arguments);
+}
+
+/**
+ * Redirect to url by replacing window.location
+ * If wait is true, sleep 1s and wait for the server i.e. after a restart.
+ */
+function redirect (url, wait) {
+ // Dont display a dialog if some xmlhttprequest are in progress
+ crash_manager.disable();
+
+ var load = function() {
+ var old = "" + window.location;
+ var old_no_hash = old.split("#")[0];
+ var url_no_hash = url.split("#")[0];
+ location.assign(url);
+ if (old_no_hash === url_no_hash) {
+ location.reload(true);
+ }
+ };
+
+ var wait_server = function() {
+ session.rpc("/web/webclient/version_info", {}).done(load).fail(function() {
+ setTimeout(wait_server, 250);
+ });
+ };
+
+ if (wait) {
+ setTimeout(wait_server, 1000);
+ } else {
+ load();
+ }
+}
+
+// * Client action to reload the whole interface.
+// * If params.menu_id, it opens the given menu entry.
+// * If params.wait, reload will wait the openerp server to be reachable before reloading
+
+function Reload(parent, action) {
+ var params = action.params || {};
+ var menu_id = params.menu_id || false;
+ var l = window.location;
+
+ var sobj = $.deparam(l.search.substr(1));
+ if (params.url_search) {
+ sobj = _.extend(sobj, params.url_search);
+ }
+ var search = '?' + $.param(sobj);
+
+ var hash = l.hash;
+ if (menu_id) {
+ hash = "#menu_id=" + menu_id;
+ }
+ var url = l.protocol + "//" + l.host + l.pathname + search + hash;
+
+ redirect(url, params.wait);
+}
+
+core.action_registry.add("reload", Reload);
+
+
+/**
+ * Client action to go back home.
+ */
+function Home (parent, action) {
+ var url = '/' + (window.location.search || '');
+ redirect(url, action && action.params && action.params.wait);
+}
+core.action_registry.add("home", Home);
+
+/**
+ * Client action to go back in breadcrumb history.
+ * If can't go back in history stack, will go back to home.
+ */
+function HistoryBack (parent) {
+ parent.history_back().fail(function () {
+ Home(parent);
+ });
+}
+core.action_registry.add("history_back", HistoryBack);
+
+function login() {
+ redirect('/web/login');
+}
+core.action_registry.add("login", login);
+
+function logout() {
+ redirect('/web/session/logout');
+}
+core.action_registry.add("logout", logout);
+
+/**
+ * Client action to refresh the session context (making sure
+ * HTTP requests will have the right one) then reload the
+ * whole interface.
+ */
+function ReloadContext (parent, action) {
+ // side-effect of get_session_info is to refresh the session context
+ session.rpc("/web/session/get_session_info", {}).then(function() {
+ Reload(parent, action);
+ });
+}
+core.action_registry.add("reload_context", ReloadContext);
+
+// nvd3 customization
+//-------------------------------------------------------------------------
+nv.dev = false; // sets nvd3 library in production mode
+
+// monkey patch nvd3 to allow removing eventhandler on windowresize events
+// see https://github.com/novus/nvd3/pull/396 for more details
+
+// Adds a resize listener to the window.
+nv.utils.onWindowResize = function(fun) {
+ if (fun === null) return;
+ window.addEventListener('resize', fun);
+};
+
+// Backwards compatibility with current API.
+nv.utils.windowResize = nv.utils.onWindowResize;
+
+// Removes a resize listener from the window.
+nv.utils.offWindowResize = function(fun) {
+ if (fun === null) return;
+ window.removeEventListener('resize', fun);
+};
+
+// monkey patch nvd3 to prevent crashes when user changes view and nvd3 tries
+// to remove tooltips after 500 ms... seriously nvd3, what were you thinking?
+nv.tooltip.cleanup = function () {
+ $('.nvtooltip').remove();
+};
+
+// Bootstrap customization
+//-------------------------------------------------------------------------
+/* Bootstrap defaults overwrite */
+$.fn.tooltip.Constructor.DEFAULTS.placement = 'auto top';
+$.fn.tooltip.Constructor.DEFAULTS.html = true;
+$.fn.tooltip.Constructor.DEFAULTS.trigger = 'hover focus click';
+$.fn.tooltip.Constructor.DEFAULTS.container = 'body';
+//overwrite bootstrap tooltip method to prevent showing 2 tooltip at the same time
+var bootstrap_show_function = $.fn.tooltip.Constructor.prototype.show;
+$.fn.modal.Constructor.prototype.enforceFocus = function () { };
+$.fn.tooltip.Constructor.prototype.show = function () {
+ $('.tooltip').remove();
+ //the following fix the bug when using placement
+ //auto and the parent element does not exist anymore resulting in
+ //an error. This should be remove once we updade bootstrap to a version that fix the bug
+ //edit: bug has been fixed here : https://github.com/twbs/bootstrap/pull/13752
+ var e = $.Event('show.bs.' + this.type);
+ var inDom = $.contains(document.documentElement, this.$element[0]);
+ if (e.isDefaultPrevented() || !inDom) return;
+ return bootstrap_show_function.call(this);
+};
+
+// jquery customization
+//-------------------------------------------------------------------------
+jQuery.expr[":"].Contains = jQuery.expr.createPseudo(function(arg) {
+ return function( elem ) {
+ return jQuery(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0;
+ };
+});
+
+/** Custom jQuery plugins */
+$.fn.getAttributes = function() {
+ var o = {};
+ if (this.length) {
+ for (var attr, i = 0, attrs = this[0].attributes, l = attrs.length; i < l; i++) {
+ attr = attrs.item(i);
+ o[attr.nodeName] = attr.value;
+ }
+ }
+ return o;
+};
+$.fn.openerpClass = function(additionalClass) {
+ // This plugin should be applied on top level elements
+ additionalClass = additionalClass || '';
+ if (!!$.browser.msie) {
+ additionalClass += ' openerp_ie';
+ }
+ return this.each(function() {
+ $(this).addClass('openerp ' + additionalClass);
+ });
+};
+$.fn.openerpBounce = function() {
+ return this.each(function() {
+ $(this).css('box-sizing', 'content-box').effect('bounce', {distance: 18, times: 5}, 250);
+ });
+};
+
+// jquery autocomplete tweak to allow html and classnames
+var proto = $.ui.autocomplete.prototype,
+ initSource = proto._initSource;
+
+function filter( array, term ) {
+ var matcher = new RegExp( $.ui.autocomplete.escapeRegex(term), "i" );
+ return $.grep( array, function(value_) {
+ return matcher.test( $( "<div>" ).html( value_.label || value_.value || value_ ).text() );
+ });
+}
+
+$.extend( proto, {
+ _initSource: function() {
+ if ( this.options.html && $.isArray(this.options.source) ) {
+ this.source = function( request, response ) {
+ response( filter( this.options.source, request.term ) );
+ };
+ } else {
+ initSource.call( this );
+ }
+ },
+
+ _renderItem: function( ul, item) {
+ return $( "<li></li>" )
+ .data( "item.autocomplete", item )
+ .append( $( "<a></a>" )[ this.options.html ? "html" : "text" ]( item.label ) )
+ .appendTo( ul )
+ .addClass(item.classname);
+ }
+});
+
+
+
+
+return {
+ blockUI: blockUI,
+ unblockUI: unblockUI,
+ redirect: redirect,
+};
+
+});
+
+odoo.define('web.session', ['web.Session'], function (require) {
+ var Session = require('web.Session');
+ var modules = odoo._modules;
+ return new Session(undefined, undefined, {modules:modules});
+});
+
diff --git a/addons/web/static/src/js/framework/mixins.js b/addons/web/static/src/js/framework/mixins.js
new file mode 100644
index 00000000000..102b75a9e4b
--- /dev/null
+++ b/addons/web/static/src/js/framework/mixins.js
@@ -0,0 +1,328 @@
+odoo.define('web.mixins', ['web.Class', 'web.utils'], function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var utils = require('web.utils');
+
+/**
+ * Mixin to structure objects' life-cycles folowing a parent-children
+ * relationship. Each object can a have a parent and multiple children.
+ * When an object is destroyed, all its children are destroyed too releasing
+ * any resource they could have reserved before.
+ */
+var ParentedMixin = {
+ __parentedMixin : true,
+ init: function() {
+ this.__parentedDestroyed = false;
+ this.__parentedChildren = [];
+ this.__parentedParent = null;
+ },
+ /**
+ * Set the parent of the current object. When calling this method, the
+ * parent will also be informed and will return the current object
+ * when its getChildren() method is called. If the current object did
+ * already have a parent, it is unregistered before, which means the
+ * previous parent will not return the current object anymore when its
+ * getChildren() method is called.
+ */
+ setParent : function(parent) {
+ if (this.getParent()) {
+ if (this.getParent().__parentedMixin) {
+ this.getParent().__parentedChildren = _.without(this
+ .getParent().getChildren(), this);
+ }
+ }
+ this.__parentedParent = parent;
+ if (parent && parent.__parentedMixin) {
+ parent.__parentedChildren.push(this);
+ }
+ },
+ /**
+ * Return the current parent of the object (or null).
+ */
+ getParent : function() {
+ return this.__parentedParent;
+ },
+ /**
+ * Return a list of the children of the current object.
+ */
+ getChildren : function() {
+ return _.clone(this.__parentedChildren);
+ },
+ /**
+ * Returns true if destroy() was called on the current object.
+ */
+ isDestroyed : function() {
+ return this.__parentedDestroyed;
+ },
+ /**
+ Utility method to only execute asynchronous actions if the current
+ object has not been destroyed.
+
+ @param {$.Deferred} promise The promise representing the asynchronous
+ action.
+ @param {bool} [reject=false] If true, the returned promise will be
+ rejected with no arguments if the current
+ object is destroyed. If false, the
+ returned promise will never be resolved
+ or rejected.
+ @returns {$.Deferred} A promise that will mirror the given promise if
+ everything goes fine but will either be rejected
+ with no arguments or never resolved if the
+ current object is destroyed.
+ */
+ alive: function(promise, reject) {
+ var self = this;
+ return $.Deferred(function (def) {
+ promise.then(function () {
+ if (!self.isDestroyed()) {
+ def.resolve.apply(def, arguments);
+ }
+ }, function () {
+ if (!self.isDestroyed()) {
+ def.reject.apply(def, arguments);
+ }
+ }).always(function () {
+ if (reject) {
+ // noop if def already resolved or rejected
+ def.reject();
+ }
+ // otherwise leave promise in limbo
+ });
+ }).promise();
+ },
+ /**
+ * Inform the object it should destroy itself, releasing any
+ * resource it could have reserved.
+ */
+ destroy : function() {
+ _.each(this.getChildren(), function(el) {
+ el.destroy();
+ });
+ this.setParent(undefined);
+ this.__parentedDestroyed = true;
+ },
+ /**
+ * Find the closest ancestor matching predicate
+ */
+ findAncestor: function (predicate) {
+ var ancestor = this;
+ while (ancestor && !(predicate(ancestor)) && ancestor.getParent) {
+ ancestor = ancestor.getParent();
+ }
+ return ancestor;
+ },
+};
+
+/**
+ * Backbone's events. Do not ever use it directly, use EventDispatcherMixin instead.
+ *
+ * This class just handle the dispatching of events, it is not meant to be extended,
+ * nor used directly. All integration with parenting and automatic unregistration of
+ * events is done in EventDispatcherMixin.
+ *
+ * Copyright notice for the following Class:
+ *
+ * (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc.
+ * Backbone may be freely distributed under the MIT license.
+ * For all details and documentation:
+ * http://backbonejs.org
+ *
+ */
+var Events = Class.extend({
+ on : function(events, callback, context) {
+ var ev;
+ events = events.split(/\s+/);
+ var calls = this._callbacks || (this._callbacks = {});
+ while ((ev = events.shift())) {
+ var list = calls[ev] || (calls[ev] = {});
+ var tail = list.tail || (list.tail = list.next = {});
+ tail.callback = callback;
+ tail.context = context;
+ list.tail = tail.next = {};
+ }
+ return this;
+ },
+
+ off : function(events, callback, context) {
+ var ev, calls, node;
+ if (!events) {
+ delete this._callbacks;
+ } else if ((calls = this._callbacks)) {
+ events = events.split(/\s+/);
+ while ((ev = events.shift())) {
+ node = calls[ev];
+ delete calls[ev];
+ if (!callback || !node)
+ continue;
+ while ((node = node.next) && node.next) {
+ if (node.callback === callback
+ && (!context || node.context === context))
+ continue;
+ this.on(ev, node.callback, node.context);
+ }
+ }
+ }
+ return this;
+ },
+
+ callbackList: function() {
+ var lst = [];
+ _.each(this._callbacks || {}, function(el, eventName) {
+ var node = el;
+ while ((node = node.next) && node.next) {
+ lst.push([eventName, node.callback, node.context]);
+ }
+ });
+ return lst;
+ },
+
+ trigger : function(events) {
+ var event, node, calls, tail, args, all, rest;
+ if (!(calls = this._callbacks))
+ return this;
+ all = calls.all;
+ (events = events.split(/\s+/)).push(null);
+ // Save references to the current heads & tails.
+ while ((event = events.shift())) {
+ if (all)
+ events.push({
+ next : all.next,
+ tail : all.tail,
+ event : event
+ });
+ if (!(node = calls[event]))
+ continue;
+ events.push({
+ next : node.next,
+ tail : node.tail
+ });
+ }
+ rest = Array.prototype.slice.call(arguments, 1);
+ while ((node = events.pop())) {
+ tail = node.tail;
+ args = node.event ? [ node.event ].concat(rest) : rest;
+ while ((node = node.next) !== tail) {
+ node.callback.apply(node.context || this, args);
+ }
+ }
+ return this;
+ }
+});
+
+/**
+ Mixin containing an event system. Events are also registered by specifying the target object
+ (the object which will receive the event when it is raised). Both the event-emitting object
+ and the target object store or reference to each other. This is used to correctly remove all
+ reference to the event handler when any of the object is destroyed (when the destroy() method
+ from ParentedMixin is called). Removing those references is necessary to avoid memory leak
+ and phantom events (events which are raised and sent to a previously destroyed object).
+*/
+var EventDispatcherMixin = _.extend({}, ParentedMixin, {
+ __eventDispatcherMixin: true,
+ init: function() {
+ ParentedMixin.init.call(this);
+ this.__edispatcherEvents = new Events();
+ this.__edispatcherRegisteredEvents = [];
+ },
+ on: function(events, dest, func) {
+ var self = this;
+ if (typeof func !== "function") {
+ throw new Error("Event handler must be a function.");
+ }
+ events = events.split(/\s+/);
+ _.each(events, function(eventName) {
+ self.__edispatcherEvents.on(eventName, func, dest);
+ if (dest && dest.__eventDispatcherMixin) {
+ dest.__edispatcherRegisteredEvents.push({name: eventName, func: func, source: self});
+ }
+ });
+ return this;
+ },
+ off: function(events, dest, func) {
+ var self = this;
+ events = events.split(/\s+/);
+ _.each(events, function(eventName) {
+ self.__edispatcherEvents.off(eventName, func, dest);
+ if (dest && dest.__eventDispatcherMixin) {
+ dest.__edispatcherRegisteredEvents = _.filter(dest.__edispatcherRegisteredEvents, function(el) {
+ return !(el.name === eventName && el.func === func && el.source === self);
+ });
+ }
+ });
+ return this;
+ },
+ trigger: function() {
+ this.__edispatcherEvents.trigger.apply(this.__edispatcherEvents, arguments);
+ return this;
+ },
+ destroy: function() {
+ var self = this;
+ _.each(this.__edispatcherRegisteredEvents, function(event) {
+ event.source.__edispatcherEvents.off(event.name, event.func, self);
+ });
+ this.__edispatcherRegisteredEvents = [];
+ _.each(this.__edispatcherEvents.callbackList(), function(cal) {
+ this.off(cal[0], cal[2], cal[1]);
+ }, this);
+ this.__edispatcherEvents.off();
+ ParentedMixin.destroy.call(this);
+ }
+});
+
+var PropertiesMixin = _.extend({}, EventDispatcherMixin, {
+ init: function() {
+ EventDispatcherMixin.init.call(this);
+ this.__getterSetterInternalMap = {};
+ },
+ set: function(arg1, arg2, arg3) {
+ var map;
+ var options;
+ if (typeof arg1 === "string") {
+ map = {};
+ map[arg1] = arg2;
+ options = arg3 || {};
+ } else {
+ map = arg1;
+ options = arg2 || {};
+ }
+ var self = this;
+ var changed = false;
+ _.each(map, function(val, key) {
+ var tmp = self.__getterSetterInternalMap[key];
+ if (tmp === val)
+ return;
+ // seriously, why are you doing this? it is obviously a stupid design.
+ // the properties mixin should not be concerned with handling fields details.
+ // this also has the side effect of introducing a dependency on utils. Todo:
+ // remove this, or move it elsewhere. Also, learn OO programming.
+ if (key === 'value' && self.field && self.field.type === 'float' && tmp && val){
+ var precision = self.field.digits ? self.field.digits[1] : 2;
+ if (utils.float_is_zero(tmp - val, precision)) {
+ return;
+ }
+ }
+ changed = true;
+ self.__getterSetterInternalMap[key] = val;
+ if (! options.silent)
+ self.trigger("change:" + key, self, {
+ oldValue: tmp,
+ newValue: val
+ });
+ });
+ if (changed)
+ self.trigger("change", self);
+ },
+ get: function(key) {
+ return this.__getterSetterInternalMap[key];
+ }
+});
+
+return {
+ ParentedMixin: ParentedMixin,
+ EventDispatcherMixin: EventDispatcherMixin,
+ PropertiesMixin: PropertiesMixin,
+};
+
+});
+
diff --git a/addons/web/static/src/js/framework/model.js b/addons/web/static/src/js/framework/model.js
new file mode 100644
index 00000000000..f1a1555fd6f
--- /dev/null
+++ b/addons/web/static/src/js/framework/model.js
@@ -0,0 +1,191 @@
+odoo.define('web.Model', ['web.Class', 'web.session', 'web.utils'], function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var session = require('web.session');
+var utils = require('web.utils');
+
+var Model = Class.extend({
+ /**
+ new openerp.web.Model([session,] model_name[, context[, domain]])
+
+ @constructs instance.web.Model
+ @extends instance.web.Class
+
+ @param {openerp.web.Session} [session] The session object used to communicate with
+ the server.
+ @param {String} model_name name of the OpenERP model this object is bound to
+ @param {Object} [context]
+ @param {Array} [domain]
+ */
+ init: function(name, context, domain) {
+ this.name = name;
+ this._context = context || {};
+ this._domain = domain || [];
+ },
+ /**
+ * @deprecated does not allow to specify kwargs, directly use call() instead
+ */
+ get_func: function (method_name) {
+ var self = this;
+ return function () {
+ return self.call(method_name, _.toArray(arguments));
+ };
+ },
+ /**
+ * Call a method (over RPC) on the bound OpenERP model.
+ *
+ * @param {String} method name of the method to call
+ * @param {Array} [args] positional arguments
+ * @param {Object} [kwargs] keyword arguments
+ * @param {Object} [options] additional options for the rpc() method
+ * @returns {jQuery.Deferred<>} call result
+ */
+ call: function (method, args, kwargs, options) {
+ args = args || [];
+ kwargs = kwargs || {};
+ if (!_.isArray(args)) {
+ // call(method, kwargs)
+ kwargs = args;
+ args = [];
+ }
+ var call_kw = '/web/dataset/call_kw/' + this.name + '/' + method;
+ return session.rpc(call_kw, {
+ model: this.name,
+ method: method,
+ args: args,
+ kwargs: kwargs
+ }, options);
+ },
+ /**
+ * Executes a signal on the designated workflow, on the bound OpenERP model
+ *
+ * @param {Number} id workflow identifier
+ * @param {String} signal signal to trigger on the workflow
+ */
+ exec_workflow: function (id, signal) {
+ return session.rpc('/web/dataset/exec_workflow', {
+ model: this.name,
+ id: id,
+ signal: signal
+ });
+ },
+ /**
+ * Performs a fields_view_get and apply postprocessing.
+ * return a {$.Deferred} resolved with the fvg
+ *
+ * @param {Object} args
+ * @param {String|Object} args.model instance.web.Model instance or string repr of the model
+ * @param {Object} [args.context] context if args.model is a string
+ * @param {Number} [args.view_id] id of the view to be loaded, default view if null
+ * @param {String} [args.view_type] type of view to be loaded if view_id is null
+ * @param {Boolean} [args.toolbar=false] get the toolbar definition
+ */
+ fields_view_get: function (options) {
+
+ return this.call('fields_view_get', {
+ view_id: options.view_id,
+ view_type: options.view_type,
+ context: options.context,
+ toolbar: options.toolbar || false
+ }).then(function postprocess(fvg) {
+ var doc = $.parseXML(fvg.arch).documentElement;
+ fvg.arch = utils.xml_to_json(doc, (doc.nodeName.toLowerCase() !== 'kanban'));
+ if ('id' in fvg.fields) {
+ // Special case for id's
+ var id_field = fvg.fields.id;
+ id_field.original_type = id_field.type;
+ id_field.type = 'id';
+ }
+ _.each(fvg.fields, function(field) {
+ _.each(field.views || {}, function(view) {
+ postprocess(view);
+ });
+ });
+ return fvg;
+ });
+ }
+});
+
+return Model;
+});
+
+odoo.define('web.DataModel', ['web.data', 'web.Model', 'web.pyeval', 'web.session'], function (require) {
+"use strict";
+
+var data = require('web.data');
+var Model = require('web.Model');
+var pyeval = require('web.pyeval');
+var session = require('web.session');
+
+Model.include({
+ /**
+ * Fetches a Query instance bound to this model, for searching
+ *
+ * @param {Array<String>} [fields] fields to ultimately fetch during the search
+ * @returns {instance.web.Query}
+ */
+ query: function (fields) {
+ return new data.Query(this, fields);
+ },
+ /**
+ * Fetches the model's domain, combined with the provided domain if any
+ *
+ * @param {Array} [domain] to combine with the model's internal domain
+ * @returns {instance.web.CompoundDomain} The model's internal domain, or the AND-ed union of the model's internal domain and the provided domain
+ */
+ domain: function (domain) {
+ if (!domain) { return this._domain; }
+ return new data.CompoundDomain(this._domain, domain);
+ },
+ /**
+ * Fetches the combination of the user's context and the domain context,
+ * combined with the provided context if any
+ *
+ * @param {Object} [context] to combine with the model's internal context
+ * @returns {instance.web.CompoundContext} The union of the user's context and the model's internal context, as well as the provided context if any. In that order.
+ */
+ context: function (context) {
+ return new data.CompoundContext(session.user_context, this._context, context || {});
+ },
+ /**
+ * Call a method (over RPC) on the bound OpenERP model.
+ *
+ * @param {String} method name of the method to call
+ * @param {Array} [args] positional arguments
+ * @param {Object} [kwargs] keyword arguments
+ * @param {Object} [options] additional options for the rpc() method
+ * @returns {jQuery.Deferred<>} call result
+ */
+ call: function (method, args, kwargs, options) {
+ args = args || [];
+ kwargs = kwargs || {};
+ if (!_.isArray(args)) {
+ // call(method, kwargs)
+ kwargs = args;
+ args = [];
+ }
+ pyeval.ensure_evaluated(args, kwargs);
+ var call_kw = '/web/dataset/call_kw/' + this.name + '/' + method;
+ return session.rpc(call_kw, {
+ model: this.name,
+ method: method,
+ args: args,
+ kwargs: kwargs
+ }, options);
+ },
+ call_button: function (method, args) {
+ pyeval.ensure_evaluated(args, {});
+ return session.rpc('/web/dataset/call_button', {
+ model: this.name,
+ method: method,
+ // Should not be necessary anymore. Integrate remote in this?
+ domain_id: null,
+ context_id: args.length - 1,
+ args: args || []
+ });
+ },
+
+});
+
+});
diff --git a/addons/web/static/src/js/framework/pyeval.js b/addons/web/static/src/js/framework/pyeval.js
new file mode 100644
index 00000000000..18d43db318b
--- /dev/null
+++ b/addons/web/static/src/js/framework/pyeval.js
@@ -0,0 +1,1018 @@
+odoo.define('web.pyeval', ['web.core', 'web.session', 'web.utils'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var session = require('web.session');
+var utils = require('web.utils');
+
+var _t = core._t;
+var py = window.py; // to silence linters
+/*
+ * py.js helpers and setup
+ */
+
+var obj = function () {};
+obj.prototype = py.object;
+var asJS = function (arg) {
+ if (arg instanceof obj) {
+ return arg.toJSON();
+ }
+ return arg;
+};
+
+var datetime = py.PY_call(py.object);
+
+var zero = py.float.fromJSON(0);
+
+// Port from pypy/lib_pypy/datetime.py
+var DAYS_IN_MONTH = [null, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+var DAYS_BEFORE_MONTH = [null];
+var dbm = 0;
+
+for (var i=1; i<DAYS_IN_MONTH.length; ++i) {
+ DAYS_BEFORE_MONTH.push(dbm);
+ dbm += DAYS_IN_MONTH[i];
+}
+
+function is_leap (year) {
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
+}
+
+function days_before_year (year) {
+ var y = year - 1;
+ return y*365 + Math.floor(y/4) - Math.floor(y/100) + Math.floor(y/400);
+}
+
+function days_in_month (year, month) {
+ if (month === 2 && is_leap(year)) {
+ return 29;
+ }
+ return DAYS_IN_MONTH[month];
+}
+
+function days_before_month (year, month) {
+ var post_leap_feb = month > 2 && is_leap(year);
+ return DAYS_BEFORE_MONTH[month] + (post_leap_feb ? 1 : 0);
+}
+
+function ymd2ord (year, month, day) {
+ var dim = days_in_month(year, month);
+ if (!(1 <= day && day <= dim)) {
+ throw new Error("ValueError: day must be in 1.." + dim);
+ }
+ return days_before_year(year) +
+ days_before_month(year, month) +
+ day;
+}
+
+var DI400Y = days_before_year(401);
+var DI100Y = days_before_year(101);
+var DI4Y = days_before_year(5);
+
+function ord2ymd (n) {
+ --n;
+ var n400, n100, n4, n1, n0;
+ utils.divmod(n, DI400Y, function (_n400, n) {
+ n400 = _n400;
+ utils.divmod(n, DI100Y, function (_n100, n) {
+ n100 = _n100;
+ utils.divmod(n, DI4Y, function (_n4, n) {
+ n4 = _n4;
+ utils.divmod(n, 365, function (_n1, n) {
+ n1 = _n1;
+ n0 = n;
+ });
+ });
+ });
+ });
+
+ n = n0;
+ var year = n400 * 400 + 1 + n100 * 100 + n4 * 4 + n1;
+ if (n1 == 4 || n100 == 100) {
+ utils.assert(n0 === 0);
+ return {
+ year: year - 1,
+ month: 12,
+ day: 31
+ };
+ }
+
+ var leapyear = n1 === 3 && (n4 !== 24 || n100 == 3);
+ utils.assert(leapyear == is_leap(year));
+ var month = (n + 50) >> 5;
+ var preceding = DAYS_BEFORE_MONTH[month] + ((month > 2 && leapyear) ? 1 : 0);
+ if (preceding > n) {
+ --month;
+ preceding -= DAYS_IN_MONTH[month] + ((month === 2 && leapyear) ? 1 : 0);
+ }
+ n -= preceding;
+ return {
+ year: year,
+ month: month,
+ day: n+1
+ };
+}
+
+/**
+ * Converts the stuff passed in into a valid date, applying overflows as needed
+ */
+function tmxxx(year, month, day, hour, minute, second, microsecond) {
+ hour = hour || 0; minute = minute || 0; second = second || 0;
+ microsecond = microsecond || 0;
+
+ if (microsecond < 0 || microsecond > 999999) {
+ utils.divmod(microsecond, 1000000, function (carry, ms) {
+ microsecond = ms;
+ second += carry;
+ });
+ }
+ if (second < 0 || second > 59) {
+ utils.divmod(second, 60, function (carry, s) {
+ second = s;
+ minute += carry;
+ });
+ }
+ if (minute < 0 || minute > 59) {
+ utils.divmod(minute, 60, function (carry, m) {
+ minute = m;
+ hour += carry;
+ });
+ }
+ if (hour < 0 || hour > 23) {
+ utils.divmod(hour, 24, function (carry, h) {
+ hour = h;
+ day += carry;
+ });
+ }
+ // That was easy. Now it gets muddy: the proper range for day
+ // can't be determined without knowing the correct month and year,
+ // but if day is, e.g., plus or minus a million, the current month
+ // and year values make no sense (and may also be out of bounds
+ // themselves).
+ // Saying 12 months == 1 year should be non-controversial.
+ if (month < 1 || month > 12) {
+ utils.divmod(month-1, 12, function (carry, m) {
+ month = m + 1;
+ year += carry;
+ });
+ }
+ // Now only day can be out of bounds (year may also be out of bounds
+ // for a datetime object, but we don't care about that here).
+ // If day is out of bounds, what to do is arguable, but at least the
+ // method here is principled and explainable.
+ var dim = days_in_month(year, month);
+ if (day < 1 || day > dim) {
+ // Move day-1 days from the first of the month. First try to
+ // get off cheap if we're only one day out of range (adjustments
+ // for timezone alone can't be worse than that).
+ if (day === 0) {
+ --month;
+ if (month > 0) {
+ day = days_in_month(year, month);
+ } else {
+ --year; month=12; day=31;
+ }
+ } else if (day == dim + 1) {
+ ++month;
+ day = 1;
+ if (month > 12) {
+ month = 1;
+ ++year;
+ }
+ } else {
+ var r = ord2ymd(ymd2ord(year, month, 1) + (day - 1));
+ year = r.year;
+ month = r.month;
+ day = r.day;
+ }
+ }
+ return {
+ year: year,
+ month: month,
+ day: day,
+ hour: hour,
+ minute: minute,
+ second: second,
+ microsecond: microsecond
+ };
+}
+
+datetime.timedelta = py.type('timedelta', null, {
+ __init__: function () {
+ var args = py.PY_parseArgs(arguments, [
+ ['days', zero], ['seconds', zero], ['microseconds', zero],
+ ['milliseconds', zero], ['minutes', zero], ['hours', zero],
+ ['weeks', zero]
+ ]);
+
+ var d = 0, s = 0, m = 0;
+ var days = args.days.toJSON() + args.weeks.toJSON() * 7;
+ var seconds = args.seconds.toJSON()
+ + args.minutes.toJSON() * 60
+ + args.hours.toJSON() * 3600;
+ var microseconds = args.microseconds.toJSON()
+ + args.milliseconds.toJSON() * 1000;
+
+ // Get rid of all fractions, and normalize s and us.
+ // Take a deep breath <wink>.
+ var daysecondsfrac = utils.modf(days, function (dayfrac, days) {
+ d = days;
+ if (dayfrac) {
+ return utils.modf(dayfrac * 24 * 3600, function (dsf, dsw) {
+ s = dsw;
+ return dsf;
+ });
+ }
+ return 0;
+ });
+
+ var secondsfrac = utils.modf(seconds, function (sf, s) {
+ seconds = s;
+ return sf + daysecondsfrac;
+ });
+ utils.divmod(seconds, 24*3600, function (days, seconds) {
+ d += days;
+ s += seconds;
+ });
+ // seconds isn't referenced again before redefinition
+
+ microseconds += secondsfrac * 1e6;
+ utils.divmod(microseconds, 1000000, function (seconds, microseconds) {
+ utils.divmod(seconds, 24*3600, function (days, seconds) {
+ d += days;
+ s += seconds;
+ m += Math.round(microseconds);
+ });
+ });
+
+ // Carrying still possible here?
+
+ this.days = d;
+ this.seconds = s;
+ this.microseconds = m;
+ },
+ __str__: function () {
+ var hh, mm, ss;
+ utils.divmod(this.seconds, 60, function (m, s) {
+ utils.divmod(m, 60, function (h, m) {
+ hh = h;
+ mm = m;
+ ss = s;
+ });
+ });
+ var s = _.str.sprintf("%d:%02d:%02d", hh, mm, ss);
+ if (this.days) {
+ s = _.str.sprintf("%d day%s, %s",
+ this.days,
+ (this.days != 1 && this.days != -1) ? 's' : '',
+ s);
+ }
+ if (this.microseconds) {
+ s = _.str.sprintf("%s.%06d", s, this.microseconds);
+ }
+ return py.str.fromJSON(s);
+ },
+ __eq__: function (other) {
+ if (!py.PY_isInstance(other, datetime.timedelta)) {
+ return py.False;
+ }
+
+ return (this.days === other.days
+ && this.seconds === other.seconds
+ && this.microseconds === other.microseconds)
+ ? py.True : py.False;
+ },
+ __add__: function (other) {
+ if (!py.PY_isInstance(other, datetime.timedelta)) {
+ return py.NotImplemented;
+ }
+ return py.PY_call(datetime.timedelta, [
+ py.float.fromJSON(this.days + other.days),
+ py.float.fromJSON(this.seconds + other.seconds),
+ py.float.fromJSON(this.microseconds + other.microseconds)
+ ]);
+ },
+ __radd__: function (other) { return this.__add__(other); },
+ __sub__: function (other) {
+ if (!py.PY_isInstance(other, datetime.timedelta)) {
+ return py.NotImplemented;
+ }
+ return py.PY_call(datetime.timedelta, [
+ py.float.fromJSON(this.days - other.days),
+ py.float.fromJSON(this.seconds - other.seconds),
+ py.float.fromJSON(this.microseconds - other.microseconds)
+ ]);
+ },
+ __rsub__: function (other) {
+ if (!py.PY_isInstance(other, datetime.timedelta)) {
+ return py.NotImplemented;
+ }
+ return this.__neg__().__add__(other);
+ },
+ __neg__: function () {
+ return py.PY_call(datetime.timedelta, [
+ py.float.fromJSON(-this.days),
+ py.float.fromJSON(-this.seconds),
+ py.float.fromJSON(-this.microseconds)
+ ]);
+ },
+ __pos__: function () { return this; },
+ __mul__: function (other) {
+ if (!py.PY_isInstance(other, py.float)) {
+ return py.NotImplemented;
+ }
+ var n = other.toJSON();
+ return py.PY_call(datetime.timedelta, [
+ py.float.fromJSON(this.days * n),
+ py.float.fromJSON(this.seconds * n),
+ py.float.fromJSON(this.microseconds * n)
+ ]);
+ },
+ __rmul__: function (other) { return this.__mul__(other); },
+ __div__: function (other) {
+ if (!py.PY_isInstance(other, py.float)) {
+ return py.NotImplemented;
+ }
+ var usec = ((this.days * 24 * 3600) + this.seconds) * 1000000
+ + this.microseconds;
+ return py.PY_call(
+ datetime.timedelta, [
+ zero, zero, py.float.fromJSON(usec / other.toJSON())]);
+ },
+ __floordiv__: function (other) { return this.__div__(other); },
+ total_seconds: function () {
+ return py.float.fromJSON(
+ this.days * 86400
+ + this.seconds
+ + this.microseconds / 1000000);
+ },
+ __nonzero__: function () {
+ return (!!this.days || !!this.seconds || !!this.microseconds)
+ ? py.True
+ : py.False;
+ }
+});
+
+datetime.datetime = py.type('datetime', null, {
+ __init__: function () {
+ var zero = py.float.fromJSON(0);
+ var args = py.PY_parseArgs(arguments, [
+ 'year', 'month', 'day',
+ ['hour', zero], ['minute', zero], ['second', zero],
+ ['microsecond', zero], ['tzinfo', py.None]
+ ]);
+ for(var key in args) {
+ if (!args.hasOwnProperty(key)) { continue; }
+ this[key] = asJS(args[key]);
+ }
+ },
+ replace: function () {
+ var args = py.PY_parseArgs(arguments, [
+ ['year', py.None], ['month', py.None], ['day', py.None],
+ ['hour', py.None], ['minute', py.None], ['second', py.None],
+ ['microsecond', py.None] // FIXME: tzinfo, can't use None as valid input
+ ]);
+ var params = {};
+ for(var key in args) {
+ if (!args.hasOwnProperty(key)) { continue; }
+
+ var arg = args[key];
+ params[key] = (arg === py.None ? this[key] : asJS(arg));
+ }
+ return py.PY_call(datetime.datetime, params);
+ },
+ strftime: function () {
+ var self = this;
+ var args = py.PY_parseArgs(arguments, 'format');
+ return py.str.fromJSON(args.format.toJSON()
+ .replace(/%([A-Za-z])/g, function (m, c) {
+ switch (c) {
+ case 'Y': return _.str.sprintf('%04d', self.year);
+ case 'm': return _.str.sprintf('%02d', self.month);
+ case 'd': return _.str.sprintf('%02d', self.day);
+ case 'H': return _.str.sprintf('%02d', self.hour);
+ case 'M': return _.str.sprintf('%02d', self.minute);
+ case 'S': return _.str.sprintf('%02d', self.second);
+ }
+ throw new Error('ValueError: No known conversion for ' + m);
+ }));
+ },
+ now: py.classmethod.fromJSON(function () {
+ var d = new Date();
+ return py.PY_call(datetime.datetime, [
+ d.getFullYear(), d.getMonth() + 1, d.getDate(),
+ d.getHours(), d.getMinutes(), d.getSeconds(),
+ d.getMilliseconds() * 1000]);
+ }),
+ today: py.classmethod.fromJSON(function () {
+ var dt_class = py.PY_getAttr(datetime, 'datetime');
+ return py.PY_call(py.PY_getAttr(dt_class, 'now'));
+ }),
+ utcnow: py.classmethod.fromJSON(function () {
+ var d = new Date();
+ return py.PY_call(datetime.datetime,
+ [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(),
+ d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(),
+ d.getUTCMilliseconds() * 1000]);
+ }),
+ combine: py.classmethod.fromJSON(function () {
+ var args = py.PY_parseArgs(arguments, 'date time');
+ return py.PY_call(datetime.datetime, [
+ py.PY_getAttr(args.date, 'year'),
+ py.PY_getAttr(args.date, 'month'),
+ py.PY_getAttr(args.date, 'day'),
+ py.PY_getAttr(args.time, 'hour'),
+ py.PY_getAttr(args.time, 'minute'),
+ py.PY_getAttr(args.time, 'second')
+ ]);
+ }),
+ toJSON: function () {
+ return new Date(
+ this.year,
+ this.month - 1,
+ this.day,
+ this.hour,
+ this.minute,
+ this.second,
+ this.microsecond / 1000);
+ },
+});
+
+datetime.date = py.type('date', null, {
+ __init__: function () {
+ var args = py.PY_parseArgs(arguments, 'year month day');
+ this.year = asJS(args.year);
+ this.month = asJS(args.month);
+ this.day = asJS(args.day);
+ },
+ strftime: function () {
+ var self = this;
+ var args = py.PY_parseArgs(arguments, 'format');
+ return py.str.fromJSON(args.format.toJSON()
+ .replace(/%([A-Za-z])/g, function (m, c) {
+ switch (c) {
+ case 'Y': return self.year;
+ case 'm': return _.str.sprintf('%02d', self.month);
+ case 'd': return _.str.sprintf('%02d', self.day);
+ }
+ throw new Error('ValueError: No known conversion for ' + m);
+ }));
+ },
+ __eq__: function (other) {
+ return (this.year === other.year
+ && this.month === other.month
+ && this.day === other.day)
+ ? py.True : py.False;
+ },
+ replace: function () {
+ var args = py.PY_parseArgs(arguments, [
+ ['year', py.None], ['month', py.None], ['day', py.None]
+ ]);
+ var params = {};
+ for(var key in args) {
+ if (!args.hasOwnProperty(key)) { continue; }
+
+ var arg = args[key];
+ params[key] = (arg === py.None ? this[key] : asJS(arg));
+ }
+ return py.PY_call(datetime.date, params);
+ },
+ __add__: function (other) {
+ if (!py.PY_isInstance(other, datetime.timedelta)) {
+ return py.NotImplemented;
+ }
+ var s = tmxxx(this.year, this.month, this.day + other.days);
+ return datetime.date.fromJSON(s.year, s.month, s.day);
+ },
+ __radd__: function (other) { return this.__add__(other); },
+ __sub__: function (other) {
+ if (py.PY_isInstance(other, datetime.timedelta)) {
+ return py.PY_add(this, py.PY_negative(other));
+ }
+ if (py.PY_isInstance(other, datetime.date)) {
+ // FIXME: getattr and sub API methods
+ return py.PY_call(datetime.timedelta, [
+ py.PY_subtract(
+ py.PY_call(py.PY_getAttr(this, 'toordinal')),
+ py.PY_call(py.PY_getAttr(other, 'toordinal')))
+ ]);
+ }
+ return py.NotImplemented;
+ },
+ toordinal: function () {
+ return py.float.fromJSON(ymd2ord(this.year, this.month, this.day));
+ },
+ weekday: function () {
+ return py.float.fromJSON((this.toordinal().toJSON()+6)%7);
+ },
+ fromJSON: function (year, month, day) {
+ return py.PY_call(datetime.date, [year, month, day]);
+ },
+ today: py.classmethod.fromJSON(function () {
+ var d = new Date ();
+ return py.PY_call(datetime.date, [
+ d.getFullYear(), d.getMonth() + 1, d.getDate()]);
+ }),
+});
+/**
+ Returns the current local date, which means the date on the client (which can be different
+ compared to the date of the server).
+
+ @return {datetime.date}
+*/
+function context_today () {
+ var d = new Date();
+ return py.PY_call(
+ datetime.date, [d.getFullYear(), d.getMonth() + 1, d.getDate()]);
+}
+
+datetime.time = py.type('time', null, {
+ __init__: function () {
+ var zero = py.float.fromJSON(0);
+ var args = py.PY_parseArgs(arguments, [
+ ['hour', zero], ['minute', zero], ['second', zero], ['microsecond', zero],
+ ['tzinfo', py.None]
+ ]);
+
+ for(var k in args) {
+ if (!args.hasOwnProperty(k)) { continue; }
+ this[k] = asJS(args[k]);
+ }
+ }
+});
+
+var time = py.PY_call(py.object);
+time.strftime = py.PY_def.fromJSON(function () {
+ var args = py.PY_parseArgs(arguments, 'format');
+ var dt_class = py.PY_getAttr(datetime, 'datetime');
+ var d = py.PY_call(py.PY_getAttr(dt_class, 'utcnow'));
+ return py.PY_call(py.PY_getAttr(d, 'strftime'), [args.format]);
+});
+
+var args = _.map(('year month day '
+ + 'years months weeks days '
+ + 'weekday leapdays yearday nlyearday').split(' '), function (arg) {
+ switch (arg) {
+ case 'years':case 'months':case 'days':case 'leapdays':case 'weeks':
+ return [arg, zero];
+ case 'year':case 'month':case 'day':case 'weekday':
+ case 'yearday':case 'nlyearday':
+ return [arg, null];
+ default:
+ throw new Error("Unknown relativedelta argument " + arg);
+ }
+});
+args.unshift('*');
+
+var _utils = {
+ monthrange: function (year, month) {
+ if (month < 1 || month > 12) {
+ throw new Error("Illegal month " + month);
+ }
+
+ var day1 = this.weekday(year, month, 1);
+ var ndays = this.mdays[month] + (month == this.February && this.isleap(year));
+ return [day1, ndays];
+ },
+ weekday: function (year, month, day) {
+ var date = py.PY_call(datetime.date, [year, month, day]);
+ return py.PY_call(py.PY_getAttr(date, 'weekday'));
+ },
+ isleap: function (year) {
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
+ },
+ mdays: [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
+ January: 1,
+ February: 2
+};
+
+var relativedelta = py.type('relativedelta', null, {
+ __init__: function () {
+ this.ops = py.PY_parseArgs(arguments, args);
+ this.ops.days = py.float.fromJSON(
+ asJS(this.ops.days) + asJS(this.ops.weeks) * 7
+ );
+
+ var yday = zero;
+ if (this.ops.nlyearday) {
+ yday = this.ops.nlyearday;
+ } else if (this.ops.yearday) {
+ yday = this.ops.yearday;
+ if (asJS(this.ops.yearday) > 59) {
+ this.ops.leapdays = py.float.fromJS(-1);
+ }
+ }
+ if (py.PY_isTrue(yday)) {
+ var ydayidx = [31, 59, 90, 120, 151, 181, 212,
+ 243, 273, 304, 334, 366];
+ for(var idx=0; idx<ydayidx.length; ++idx) {
+ var ydays = ydayidx[idx];
+ if (asJS(yday) <= ydays) {
+ this.ops.month = py.float.fromJSON(idx+1);
+ if (!idx) {
+ this.ops.day = yday;
+ } else {
+ this.ops.day = py.PY_subtract(
+ yday,
+ py.float.fromJSON(ydayidx[idx-1])
+ );
+ }
+ break;
+ }
+ }
+ if (idx === ydayidx.length) {
+ throw new Error("Invalid year day (" + asJS(yday) + ")");
+ }
+ }
+ this._fix();
+ },
+ _fix: function () {
+ var months = asJS(this.ops.months);
+ if (Math.abs(months) > 11) {
+ var s = months > 0 ? 1 : -1;
+ var r = utils.divmod(months * s, 12);
+ this.ops.months = py.float.fromJSON(r.mod*s);
+ this.ops.years = py.float.fromJSON(
+ asJS(this.ops.years) + r.div*s);
+ }
+ this._has_time = 0;
+ },
+ __add__: function (other) {
+ if (!py.PY_isInstance(other, datetime.date)) {
+ return py.NotImplemented;
+ }
+ // TODO: test this whole mess
+ var year = (asJS(this.ops.year) || asJS(other.year)) + asJS(this.ops.years);
+ var month = asJS(this.ops.month) || asJS(other.month);
+ var months;
+ if (months = asJS(this.ops.months)) {
+ if (Math.abs(months) < 1 || Math.abs(months) > 12) {
+ throw new Error("Can only use relative months between -12 and +12");
+ }
+ month += months;
+ if (month > 12) {
+ year += 1;
+ month -= 12;
+ }
+ if (month < 1) {
+ year -= 1;
+ month += 12;
+ }
+ }
+
+ var day = Math.min(_utils.monthrange(year, month)[1],
+ asJS(this.ops.day) || asJS(other.day));
+
+ var repl = {
+ year: py.float.fromJSON(year),
+ month: py.float.fromJSON(month),
+ day: py.float.fromJSON(day)
+ };
+
+ var days = asJS(this.ops.days);
+ if (py.PY_isTrue(this.ops.leapdays) && month > 2 && _utils.isleap(year)) {
+ days += asJS(this.ops.leapdays);
+ }
+
+ var ret = py.PY_add(
+ py.PY_call(py.PY_getAttr(other, 'replace'), repl),
+ py.PY_call(datetime.timedelta, {
+ days: py.float.fromJSON(days)
+ })
+ );
+
+ if (this.ops.weekday) {
+ // FIXME: only handles numeric weekdays, not decorated
+ var weekday = asJS(this.ops.weekday), nth = 1;
+ var jumpdays = (Math.abs(nth) - 1) * 7;
+
+ var ret_weekday = asJS(py.PY_call(py.PY_getAttr(ret, 'weekday')));
+ if (nth > 0) {
+ jumpdays += (7-ret_weekday+weekday) % 7;
+ } else {
+ jumpdays += (ret_weekday - weekday) % 7;
+ jumpdays *= -1;
+ }
+ ret = py.PY_add(
+ ret,
+ py.PY_call(datetime.timedelta, {
+ days: py.float.fromJSON(jumpdays)
+ })
+ );
+ }
+
+ return ret;
+ },
+ __radd__: function (other) {
+ return this.__add__(other);
+ },
+ __rsub__: function (other) {
+ return this.__neg__().__radd__(other);
+ },
+ __neg__: function () {
+ return py.PY_call(relativedelta, {
+ years: py.PY_negative(this.ops.years),
+ months: py.PY_negative(this.ops.months),
+ days: py.PY_negative(this.ops.days),
+ leapdays: this.ops.leapdays,
+ year: this.ops.year,
+ month: this.ops.month,
+ day: this.ops.day,
+ weekday: this.ops.weekday
+ });
+ }
+});
+
+// recursively wraps JS objects passed into the context to attributedicts
+// which jsonify back to JS objects
+function wrap(value) {
+ if (value === null) { return py.None; }
+
+ switch (typeof value) {
+ case 'undefined': throw new Error("No conversion for undefined");
+ case 'boolean': return py.bool.fromJSON(value);
+ case 'number': return py.float.fromJSON(value);
+ case 'string': return py.str.fromJSON(value);
+ }
+
+ switch(value.constructor) {
+ case Object: return wrapping_dict.fromJSON(value);
+ case Array: return wrapping_list.fromJSON(value);
+ }
+
+ throw new Error("ValueError: unable to wrap " + value);
+}
+
+var wrapping_dict = py.type('wrapping_dict', null, {
+ __init__: function () {
+ this._store = {};
+ },
+ __getitem__: function (key) {
+ var k = key.toJSON();
+ if (!(k in this._store)) {
+ throw new Error("KeyError: '" + k + "'");
+ }
+ return wrap(this._store[k]);
+ },
+ __getattr__: function (key) {
+ return this.__getitem__(py.str.fromJSON(key));
+ },
+ get: function () {
+ var args = py.PY_parseArgs(arguments, ['k', ['d', py.None]]);
+
+ if (!(args.k.toJSON() in this._store)) { return args.d; }
+ return this.__getitem__(args.k);
+ },
+ fromJSON: function (d) {
+ var instance = py.PY_call(wrapping_dict);
+ instance._store = d;
+ return instance;
+ },
+ toJSON: function () {
+ return this._store;
+ },
+});
+
+var wrapping_list = py.type('wrapping_list', null, {
+ __init__: function () {
+ this._store = [];
+ },
+ __getitem__: function (index) {
+ return wrap(this._store[index.toJSON()]);
+ },
+ fromJSON: function (ar) {
+ var instance = py.PY_call(wrapping_list);
+ instance._store = ar;
+ return instance;
+ },
+ toJSON: function () {
+ return this._store;
+ },
+});
+
+function wrap_context (context) {
+ for (var k in context) {
+ if (!context.hasOwnProperty(k)) { continue; }
+ var val = context[k];
+
+ if (val === null) { continue; }
+ if (val.constructor === Array) {
+ context[k] = wrapping_list.fromJSON(val);
+ } else if (val.constructor === Object
+ && !py.PY_isInstance(val, py.object)) {
+ context[k] = wrapping_dict.fromJSON(val);
+ }
+ }
+ return context;
+}
+
+function eval_contexts (contexts, evaluation_context) {
+ evaluation_context = _.extend(pycontext(), evaluation_context || {});
+ return _(contexts).reduce(function (result_context, ctx) {
+ // __eval_context evaluations can lead to some of `contexts`'s
+ // values being null, skip them as well as empty contexts
+ if (_.isEmpty(ctx)) { return result_context; }
+ if (_.isString(ctx)) {
+ // wrap raw strings in context
+ ctx = { __ref: 'context', __debug: ctx };
+ }
+ var evaluated = ctx;
+ switch(ctx.__ref) {
+ case 'context':
+ evaluation_context.context = evaluation_context;
+ evaluated = py.eval(ctx.__debug, wrap_context(evaluation_context));
+ break;
+ case 'compound_context':
+ var eval_context = eval_contexts([ctx.__eval_context]);
+ evaluated = eval_contexts(
+ ctx.__contexts, _.extend({}, evaluation_context, eval_context));
+ break;
+ }
+ // add newly evaluated context to evaluation context for following
+ // siblings
+ _.extend(evaluation_context, evaluated);
+ return _.extend(result_context, evaluated);
+ }, {});
+}
+
+function eval_domains (domains, evaluation_context) {
+ evaluation_context = _.extend(pycontext(), evaluation_context || {});
+ var result_domain = [];
+ _(domains).each(function (domain) {
+ if (_.isString(domain)) {
+ // wrap raw strings in domain
+ domain = { __ref: 'domain', __debug: domain };
+ }
+ switch(domain.__ref) {
+ case 'domain':
+ evaluation_context.context = evaluation_context;
+ result_domain.push.apply(
+ result_domain, py.eval(domain.__debug, wrap_context(evaluation_context)));
+ break;
+ case 'compound_domain':
+ var eval_context = eval_contexts([domain.__eval_context]);
+ result_domain.push.apply(
+ result_domain, eval_domains(
+ domain.__domains, _.extend(
+ {}, evaluation_context, eval_context)));
+ break;
+ default:
+ result_domain.push.apply(result_domain, domain);
+ }
+ });
+ return result_domain;
+}
+
+function eval_groupbys (contexts, evaluation_context) {
+ evaluation_context = _.extend(pycontext(), evaluation_context || {});
+ var result_group = [];
+ _(contexts).each(function (ctx) {
+ if (_.isString(ctx)) {
+ // wrap raw strings in context
+ ctx = { __ref: 'context', __debug: ctx };
+ }
+ var group;
+ var evaluated = ctx;
+ switch(ctx.__ref) {
+ case 'context':
+ evaluation_context.context = evaluation_context;
+ evaluated = py.eval(ctx.__debug, wrap_context(evaluation_context));
+ break;
+ case 'compound_context':
+ var eval_context = eval_contexts([ctx.__eval_context]);
+ evaluated = eval_contexts(
+ ctx.__contexts, _.extend({}, evaluation_context, eval_context));
+ break;
+ }
+ group = evaluated.group_by;
+ if (!group) { return; }
+ if (typeof group === 'string') {
+ result_group.push(group);
+ } else if (group instanceof Array) {
+ result_group.push.apply(result_group, group);
+ } else {
+ throw new Error('Got invalid groupby {{'
+ + JSON.stringify(group) + '}}');
+ }
+ _.extend(evaluation_context, evaluated);
+ });
+ return result_group;
+}
+
+
+function pycontext () {
+ return _.extend({
+ datetime: datetime,
+ context_today: context_today,
+ time: time,
+ relativedelta: relativedelta,
+ current_date: py.PY_call(
+ time.strftime, [py.str.fromJSON('%Y-%m-%d')]),
+ }, session.user_context);
+}
+
+/**
+ * @param {String} type "domains", "contexts" or "groupbys"
+ * @param {Array} object domains or contexts to evaluate
+ * @param {Object} [context] evaluation context
+ */
+function pyeval (type, object, context, options) {
+ options = options || {};
+ context = _.extend(pycontext(), context || {});
+
+ //noinspection FallthroughInSwitchStatementJS
+ switch(type) {
+ case 'context':
+ case 'contexts':
+ if (type === 'context')
+ object = [object];
+ return eval_contexts((options.no_user_context ? [] : [session.user_context]).concat(object), context);
+ case 'domain':
+ case 'domains':
+ if (type === 'domain')
+ object = [object];
+ return eval_domains(object, context);
+ case 'groupbys':
+ return eval_groupbys(object, context);
+ }
+ throw new Error("Unknow evaluation type " + type);
+}
+
+function eval_arg (arg) {
+ if (typeof arg !== 'object' || !arg.__ref) { return arg; }
+ switch(arg.__ref) {
+ case 'domain': case 'compound_domain':
+ return pyeval('domains', [arg]);
+ case 'context': case 'compound_context':
+ return pyeval('contexts', [arg]);
+ default:
+ throw new Error(_t("Unknown nonliteral type " + arg.__ref));
+ }
+}
+
+/**
+ * If args or kwargs are unevaluated contexts or domains (compound or not),
+ * evaluated them in-place.
+ *
+ * Potentially mutates both parameters.
+ *
+ * @param args
+ * @param kwargs
+ */
+function ensure_evaluated (args, kwargs) {
+ for (var i=0; i<args.length; ++i) {
+ args[i] = eval_arg(args[i]);
+ }
+ for (var k in kwargs) {
+ if (!kwargs.hasOwnProperty(k)) { continue; }
+ kwargs[k] = eval_arg(kwargs[k]);
+ }
+}
+
+function eval_domains_and_contexts (source) {
+ return new $.Deferred(function (d) {setTimeout(function () {
+ var result;
+ try {
+ result = sync_eval_domains_and_contexts(source);
+ }
+ catch (e) {
+ result = { error: {
+ code: 400,
+ message: _t("Evaluation Error"),
+ data: {
+ type: 'local_exception',
+ debug: _.str.sprintf(
+ _t("Local evaluation failure\n%s\n\n%s"),
+ e.message, JSON.stringify(source))
+ }
+ }};
+ }
+ d.resolve(result);
+ }, 0); });
+}
+
+function sync_eval_domains_and_contexts (source) {
+ var contexts = ([session.user_context] || []).concat(source.contexts);
+ // see Session.eval_context in Python
+ return {
+ context: pyeval('contexts', contexts),
+ domain: pyeval('domains', source.domains),
+ group_by: pyeval('groupbys', source.group_by_seq || [])
+ };
+}
+
+function py_eval (expr, context) {
+ return py.eval(expr, _.extend({}, context || {}, {"true": true, "false": false, "null": null}));
+}
+
+
+return {
+ context: pycontext,
+ ensure_evaluated: ensure_evaluated,
+ eval: pyeval,
+ eval_domains_and_contexts: eval_domains_and_contexts,
+ sync_eval_domains_and_contexts: sync_eval_domains_and_contexts,
+ py_eval: py_eval,
+};
+
+});
diff --git a/addons/web/static/src/js/framework/registry.js b/addons/web/static/src/js/framework/registry.js
new file mode 100644
index 00000000000..c90614cf94a
--- /dev/null
+++ b/addons/web/static/src/js/framework/registry.js
@@ -0,0 +1,54 @@
+odoo.define('web.Registry', ['web.Class'], function (require) {
+"use strict";
+
+var Class = require('web.Class');
+
+var Registry = Class.extend({
+ init: function (mapping) {
+ this.map = Object.create(mapping || null);
+ },
+ get: function (key) {
+ return this.map[key];
+ },
+ contains: function (key) {
+ return (key in this.map);
+ },
+ /**
+ * Tries a number of keys, and returns the first object matching one of
+ * the keys.
+ *
+ * @param {Array} keys a sequence of keys to fetch the object for
+ * @returns {Class} the first class found matching an object
+ */
+ get_any: function (keys) {
+ for (var i=0; i<keys.length; i++) {
+ if (keys[i] in this.map) {
+ return this.map[keys[i]];
+ }
+ }
+ return null;
+ },
+ add: function (key, value) {
+ this.map[key] = value;
+ return this;
+ },
+ /**
+ * Creates and returns a copy of the current mapping, with the provided
+ * mapping argument added in (replacing existing keys if needed)
+ *
+ * Parent and child remain linked, a new key in the parent (which is not
+ * overwritten by the child) will appear in the child.
+ *
+ * @param {Object} [mapping={}] a mapping of keys to object-paths
+ */
+ extend: function (mapping) {
+ var child = new Registry(this.map);
+ _.extend(child.map, mapping);
+ return child;
+ },
+});
+
+return Registry;
+
+});
+
diff --git a/addons/web/static/src/js/framework/session.js b/addons/web/static/src/js/framework/session.js
new file mode 100644
index 00000000000..f81fec5ab54
--- /dev/null
+++ b/addons/web/static/src/js/framework/session.js
@@ -0,0 +1,393 @@
+odoo.define('web.Session', ['web.ajax', 'web.core', 'web.utils'], function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+var core = require('web.core');
+var utils = require('web.utils');
+
+var _t = core._t;
+var qweb = core.qweb;
+var mixins = core.mixins;
+
+var Session = core.Class.extend(mixins.EventDispatcherMixin, {
+ /**
+
+ @param parent The parent of the newly created object.
+ or `null` if the server to contact is the origin server.
+ @param {Dict} options A dictionary that can contain the following options:
+
+ * "override_session": Default to false. If true, the current session object will
+ not try to re-use a previously created session id stored in a cookie.
+ * "session_id": Default to null. If specified, the specified session_id will be used
+ by this session object. Specifying this option automatically implies that the option
+ "override_session" is set to true.
+ */
+ init: function(parent, origin, options) {
+ mixins.EventDispatcherMixin.init.call(this, parent);
+ options = options || {};
+ this.module_list = (options.modules && options.modules.slice()) || (window.odoo._modules && window.odoo._modules.slice()) || [];
+ this.server = null;
+ this.session_id = options.session_id || null;
+ this.override_session = options.override_session || !!options.session_id || false;
+ this.avoid_recursion = false;
+ this.use_cors = options.use_cors || false;
+ this.setup(origin);
+ this.debug = ($.deparam($.param.querystring()).debug !== undefined);
+ // TODO: session store in cookie should be optional
+ this.qweb_mutex = new utils.Mutex();
+ },
+ setup: function(origin, options) {
+ // must be able to customize server
+ var window_origin = location.protocol + "//" + location.host;
+ origin = origin ? origin.replace( /\/+$/, '') : window_origin;
+ if (!_.isUndefined(this.origin) && this.origin !== origin)
+ throw new Error('Session already bound to ' + this.origin);
+ else
+ this.origin = origin;
+ this.prefix = this.origin;
+ this.server = this.origin; // keep chs happy
+ this.origin_server = this.origin === window_origin;
+ options = options || {};
+ if ('use_cors' in options) {
+ this.use_cors = options.use_cors;
+ }
+ },
+ /**
+ * Setup a session
+ */
+ session_bind: function(origin) {
+ var self = this;
+ this.setup(origin);
+ qweb.default_dict._s = this.origin;
+ this.uid = null;
+ this.username = null;
+ this.user_context= {};
+ this.db = null;
+ this.module_loaded = {};
+ _(this.module_list).each(function (mod) {
+ self.module_loaded[mod] = true;
+ });
+ this.active_id = null;
+ return this.session_init();
+ },
+ /**
+ * Init a session, reloads from cookie, if it exists
+ */
+ session_init: function () {
+ var self = this;
+ return this.session_reload().then(function() {
+ var modules = self.module_list.join(',');
+ var deferred = self.load_qweb(modules);
+ if(self.session_is_valid()) {
+ return deferred.then(function() { return self.load_modules(); });
+ }
+ return $.when(
+ deferred,
+ self.rpc('/web/webclient/bootstrap_translations', {mods: self.module_list}).then(function(trans) {
+ _t.database.set_bundle(trans);
+ })
+ );
+ });
+ },
+ session_is_valid: function() {
+ var db = $.deparam.querystring().db;
+ if (db && this.db !== db) {
+ return false;
+ }
+ return !!this.uid;
+ },
+ /**
+ * The session is validated by restoration of a previous session
+ */
+ session_authenticate: function() {
+ var self = this;
+ return $.when(this._session_authenticate.apply(this, arguments)).then(function() {
+ return self.load_modules();
+ });
+ },
+ /**
+ * The session is validated either by login or by restoration of a previous session
+ */
+ _session_authenticate: function(db, login, password) {
+ var self = this;
+ var params = {db: db, login: login, password: password};
+ return this.rpc("/web/session/authenticate", params).then(function(result) {
+ if (!result.uid) {
+ return $.Deferred().reject();
+ }
+ delete result.session_id;
+ _.extend(self, result);
+ });
+ },
+ session_logout: function() {
+ $.bbq.removeState();
+ return this.rpc("/web/session/destroy", {});
+ },
+ get_cookie: function (name) {
+ if (!this.name) { return null; }
+ var nameEQ = this.name + '|' + name + '=';
+ var cookies = document.cookie.split(';');
+ for(var i=0; i<cookies.length; ++i) {
+ var cookie = cookies[i].replace(/^\s*/, '');
+ if(cookie.indexOf(nameEQ) === 0) {
+ try {
+ return JSON.parse(decodeURIComponent(cookie.substring(nameEQ.length)));
+ } catch(err) {
+ // wrong cookie, delete it
+ this.set_cookie(name, '', -1);
+ }
+ }
+ }
+ return null;
+ },
+ /**
+ * Create a new cookie with the provided name and value
+ *
+ * @private
+ * @param name the cookie's name
+ * @param value the cookie's value
+ * @param ttl the cookie's time to live, 1 year by default, set to -1 to delete
+ */
+ set_cookie: function (name, value, ttl) {
+ if (!this.name) { return; }
+ ttl = ttl || 24*60*60*365;
+ document.cookie = [
+ this.name + '|' + name + '=' + encodeURIComponent(JSON.stringify(value)),
+ 'path=/',
+ 'max-age=' + ttl,
+ 'expires=' + new Date(new Date().getTime() + ttl*1000).toGMTString()
+ ].join(';');
+ },
+ /**
+ * Load additional web addons of that instance and init them
+ *
+ */
+ load_modules: function() {
+ var self = this;
+ return this.rpc('/web/session/modules', {}).then(function(result) {
+ var all_modules = _.uniq(self.module_list.concat(result));
+ var to_load = _.difference(result, self.module_list).join(',');
+ self.module_list = all_modules;
+
+ var loaded = self.load_translations();
+ var locale = "/web/webclient/locale/" + self.user_context.lang || 'en_US';
+ var file_list = [ locale ];
+ if(to_load.length) {
+ loaded = $.when(
+ loaded,
+ self.rpc('/web/webclient/csslist', {mods: to_load}).done(self.load_css.bind(self)),
+ self.load_qweb(to_load),
+ self.rpc('/web/webclient/jslist', {mods: to_load}).done(function(files) {
+ file_list = file_list.concat(files);
+ })
+ );
+ }
+ return loaded.then(function () {
+ return self.load_js(file_list);
+ }).done(function() {
+ self.on_modules_loaded();
+ self.trigger('module_loaded');
+ });
+ });
+ },
+ load_translations: function() {
+ return _t.database.load_translations(this, this.module_list, this.user_context.lang);
+ },
+ load_css: function (files) {
+ var self = this;
+ _.each(files, function (file) {
+ ajax.loadCSS(self.url(file, null));
+ });
+ },
+ load_js: function(files) {
+ var self = this;
+ var d = $.Deferred();
+ if (files.length !== 0) {
+ var file = files.shift();
+ var url = self.url(file, null);
+ ajax.loadJS(url).done(d.resolve);
+ } else {
+ d.resolve();
+ }
+ return d;
+ },
+ load_qweb: function(mods) {
+ var self = this;
+ self.qweb_mutex.exec(function() {
+ return self.rpc('/web/proxy/load', {path: '/web/webclient/qweb?mods=' + mods}).then(function(xml) {
+ if (!xml) { return; }
+ qweb.add_template(_.str.trim(xml));
+ });
+ });
+ return self.qweb_mutex.def;
+ },
+ on_modules_loaded: function() {
+ var openerp = window.openerp;
+ for(var j=0; j<this.module_list.length; j++) {
+ var mod = this.module_list[j];
+ if(this.module_loaded[mod])
+ continue;
+ openerp[mod] = {};
+ // init module mod
+ var fct = openerp._openerp[mod];
+ if(typeof(fct) === "function") {
+ openerp._openerp[mod] = {};
+ for (var k in fct) {
+ openerp._openerp[mod][k] = fct[k];
+ }
+ fct(openerp, openerp._openerp[mod]);
+ }
+ this.module_loaded[mod] = true;
+ }
+ },
+ get_file: function (options) {
+ if (this.override_session){
+ options.data.session_id = this.session_id;
+ }
+ ajax.get_file(options);
+ },
+ synchronized_mode: function(to_execute) {
+ var synch = this.synch;
+ this.synch = true;
+ try {
+ return to_execute();
+ } finally {
+ this.synch = synch;
+ }
+ },
+ /**
+ * (re)loads the content of a session: db name, username, user id, session
+ * context and status of the support contract
+ *
+ * @returns {$.Deferred} deferred indicating the session is done reloading
+ */
+ session_reload: function () {
+ var self = this;
+ return self.rpc("/web/session/get_session_info", {}).then(function(result) {
+ delete result.session_id;
+ _.extend(self, result);
+ });
+ },
+ check_session_id: function() {
+ var self = this;
+ if (this.avoid_recursion)
+ return $.when();
+ if (this.session_id)
+ return $.when(); // we already have the session id
+ if (!this.use_cors && (this.override_session || ! this.origin_server)) {
+ // If we don't use the origin server we consider we should always create a new session.
+ // Even if some browsers could support cookies when using jsonp that behavior is
+ // not consistent and the browser creators are tending to removing that feature.
+ this.avoid_recursion = true;
+ return this.rpc("/gen_session_id", {}).then(function(result) {
+ self.session_id = result;
+ }).always(function() {
+ self.avoid_recursion = false;
+ });
+ } else {
+ // normal use case, just use the cookie
+ self.session_id = utils.get_cookie("session_id");
+ return $.when();
+ }
+ },
+ /**
+ * Executes an RPC call, registering the provided callbacks.
+ *
+ * Registers a default error callback if none is provided, and handles
+ * setting the correct session id and session context in the parameter
+ * objects
+ *
+ * @param {String} url RPC endpoint
+ * @param {Object} params call parameters
+ * @param {Object} options additional options for rpc call
+ * @param {Function} success_callback function to execute on RPC call success
+ * @param {Function} error_callback function to execute on RPC call failure
+ * @returns {jQuery.Deferred} jquery-provided ajax deferred
+ */
+ rpc: function(url, params, options) {
+ var self = this;
+ options = _.clone(options || {});
+ var shadow = options.shadow || false;
+ delete options.shadow;
+
+ return self.check_session_id().then(function() {
+ // TODO: remove
+ if (! _.isString(url)) {
+ _.extend(options, url);
+ url = url.url;
+ }
+ // TODO correct handling of timeouts
+ if (! shadow)
+ self.trigger('request');
+ var fct;
+ if (self.origin_server) {
+ fct = ajax.jsonRpc;
+ if (self.override_session) {
+ options.headers = _.extend({}, options.headers, {
+ "X-Openerp-Session-Id": self.override_session ? self.session_id || '' : ''
+ });
+ }
+ } else if (self.use_cors) {
+ fct = ajax.jsonRpc;
+ url = self.url(url, null);
+ options.session_id = self.session_id || '';
+ if (self.override_session) {
+ options.headers = _.extend({}, options.headers, {
+ "X-Openerp-Session-Id": self.override_session ? self.session_id || '' : ''
+ });
+ }
+ } else {
+ fct = ajax.jsonpRpc;
+ url = self.url(url, null);
+ options.session_id = self.session_id || '';
+ }
+ var p = fct(url, "call", params, options);
+ p = p.then(function (result) {
+ if (! shadow)
+ self.trigger('response');
+ return result;
+ }, function(type, error, textStatus, errorThrown) {
+ if (type === "server") {
+ if (! shadow)
+ self.trigger('response');
+ if (error.code === 100) {
+ self.uid = false;
+ }
+ return $.Deferred().reject(error, $.Event());
+ } else {
+ if (! shadow)
+ self.trigger('response_failed');
+ var nerror = {
+ code: -32098,
+ message: "XmlHttpRequestError " + errorThrown,
+ data: {type: "xhr"+textStatus, debug: error.responseText, objects: [error, errorThrown] }
+ };
+ return $.Deferred().reject(nerror, $.Event());
+ }
+ });
+ return p.fail(function() { // Allow deferred user to disable rpc_error call in fail
+ p.fail(function(error, event) {
+ if (!event.isDefaultPrevented()) {
+ self.trigger('error', error, event);
+ }
+ });
+ });
+ });
+ },
+ url: function(path, params) {
+ params = _.extend(params || {});
+ if (this.override_session || (! this.origin_server))
+ params.session_id = this.session_id;
+ var qs = $.param(params);
+ if (qs.length > 0)
+ qs = "?" + qs;
+ var prefix = _.any(['http://', 'https://', '//'], function(el) {
+ return path.length >= el.length && path.slice(0, el.length) === el;
+ }) ? '' : this.prefix;
+ return prefix + path + qs;
+ },
+});
+
+return Session;
+
+});
diff --git a/addons/web/static/src/js/framework/time.js b/addons/web/static/src/js/framework/time.js
new file mode 100644
index 00000000000..7a13655c39b
--- /dev/null
+++ b/addons/web/static/src/js/framework/time.js
@@ -0,0 +1,285 @@
+odoo.define('web.time', ['web.translation', 'web.utils'], function (require) {
+"use strict";
+
+var translation = require('web.translation');
+var utils = require('web.utils');
+
+var lpad = utils.lpad;
+var rpad = utils.rpad;
+var _t = translation._t;
+
+/**
+ * Replacer function for JSON.stringify, serializes Date objects to UTC
+ * datetime in the OpenERP Server format.
+ *
+ * However, if a serialized value has a toJSON method that method is called
+ * *before* the replacer is invoked. Date#toJSON exists, and thus the value
+ * passed to the replacer is a string, the original Date has to be fetched
+ * on the parent object (which is provided as the replacer's context).
+ *
+ * @param {String} k
+ * @param {Object} v
+ * @returns {Object}
+ */
+function date_to_utc (k, v) {
+ var value = this[k];
+ if (!(value instanceof Date)) { return v; }
+
+ return datetime_to_str(value);
+}
+
+/**
+ * Converts a string to a Date javascript object using OpenERP's
+ * datetime string format (exemple: '2011-12-01 15:12:35.832').
+ *
+ * The time zone is assumed to be UTC (standard for OpenERP 6.1)
+ * and will be converted to the browser's time zone.
+ *
+ * @param {String} str A string representing a datetime.
+ * @returns {Date}
+ */
+function str_to_datetime (str) {
+ if(!str) {
+ return str;
+ }
+ var regex = /^(\d\d\d\d)-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d(?:\.(\d+))?)$/;
+ var res = regex.exec(str);
+ if ( !res ) {
+ throw new Error("'" + str + "' is not a valid datetime");
+ }
+ var tmp = new Date(2000,0,1);
+ tmp.setUTCMonth(1970);
+ tmp.setUTCMonth(0);
+ tmp.setUTCDate(1);
+ tmp.setUTCFullYear(parseFloat(res[1]));
+ tmp.setUTCMonth(parseFloat(res[2]) - 1);
+ tmp.setUTCDate(parseFloat(res[3]));
+ tmp.setUTCHours(parseFloat(res[4]));
+ tmp.setUTCMinutes(parseFloat(res[5]));
+ tmp.setUTCSeconds(parseFloat(res[6]));
+ tmp.setUTCSeconds(parseFloat(res[6]));
+ tmp.setUTCMilliseconds(parseFloat(utils.rpad((res[7] || "").slice(0, 3), 3)));
+ return tmp;
+}
+
+/**
+ * Converts a string to a Date javascript object using OpenERP's
+ * date string format (exemple: '2011-12-01').
+ *
+ * As a date is not subject to time zones, we assume it should be
+ * represented as a Date javascript object at 00:00:00 in the
+ * time zone of the browser.
+ *
+ * @param {String} str A string representing a date.
+ * @returns {Date}
+ */
+function str_to_date (str) {
+ if(!str) {
+ return str;
+ }
+ var regex = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
+ var res = regex.exec(str);
+ if ( !res ) {
+ throw new Error("'" + str + "' is not a valid date");
+ }
+ var tmp = new Date(2000,0,1);
+ tmp.setFullYear(parseFloat(res[1]));
+ tmp.setMonth(parseFloat(res[2]) - 1);
+ tmp.setDate(parseFloat(res[3]));
+ tmp.setHours(0);
+ tmp.setMinutes(0);
+ tmp.setSeconds(0);
+ return tmp;
+}
+
+/**
+ * Converts a string to a Date javascript object using OpenERP's
+ * time string format (exemple: '15:12:35').
+ *
+ * The OpenERP times are supposed to always be naive times. We assume it is
+ * represented using a javascript Date with a date 1 of January 1970 and a
+ * time corresponding to the meant time in the browser's time zone.
+ *
+ * @param {String} str A string representing a time.
+ * @returns {Date}
+ */
+function str_to_time (str) {
+ if(!str) {
+ return str;
+ }
+ var regex = /^(\d\d):(\d\d):(\d\d(?:\.(\d+))?)$/;
+ var res = regex.exec(str);
+ if ( !res ) {
+ throw new Error("'" + str + "' is not a valid time");
+ }
+ var tmp = new Date();
+ tmp.setFullYear(1970);
+ tmp.setMonth(0);
+ tmp.setDate(1);
+ tmp.setHours(parseFloat(res[1]));
+ tmp.setMinutes(parseFloat(res[2]));
+ tmp.setSeconds(parseFloat(res[3]));
+ tmp.setMilliseconds(parseFloat(rpad((res[4] || "").slice(0, 3), 3)));
+ return tmp;
+}
+
+/**
+ * Converts a Date javascript object to a string using OpenERP's
+ * datetime string format (exemple: '2011-12-01 15:12:35').
+ *
+ * The time zone of the Date object is assumed to be the one of the
+ * browser and it will be converted to UTC (standard for OpenERP 6.1).
+ *
+ * @param {Date} obj
+ * @returns {String} A string representing a datetime.
+ */
+function datetime_to_str (obj) {
+ if (!obj) {
+ return false;
+ }
+ return lpad(obj.getUTCFullYear(),4) + "-" + lpad(obj.getUTCMonth() + 1,2) + "-"
+ + lpad(obj.getUTCDate(),2) + " " + lpad(obj.getUTCHours(),2) + ":"
+ + lpad(obj.getUTCMinutes(),2) + ":" + lpad(obj.getUTCSeconds(),2);
+}
+
+/**
+ * Converts a Date javascript object to a string using OpenERP's
+ * date string format (exemple: '2011-12-01').
+ *
+ * As a date is not subject to time zones, we assume it should be
+ * represented as a Date javascript object at 00:00:00 in the
+ * time zone of the browser.
+ *
+ * @param {Date} obj
+ * @returns {String} A string representing a date.
+ */
+function date_to_str (obj) {
+ if (!obj) {
+ return false;
+ }
+ return lpad(obj.getFullYear(),4) + "-" + lpad(obj.getMonth() + 1,2) + "-"
+ + lpad(obj.getDate(),2);
+}
+
+/**
+ * Converts a Date javascript object to a string using OpenERP's
+ * time string format (exemple: '15:12:35').
+ *
+ * The OpenERP times are supposed to always be naive times. We assume it is
+ * represented using a javascript Date with a date 1 of January 1970 and a
+ * time corresponding to the meant time in the browser's time zone.
+ *
+ * @param {Date} obj
+ * @returns {String} A string representing a time.
+ */
+function time_to_str (obj) {
+ if (!obj) {
+ return false;
+ }
+ return lpad(obj.getHours(),2) + ":" + lpad(obj.getMinutes(),2) + ":"
+ + lpad(obj.getSeconds(),2);
+}
+
+function auto_str_to_date (value) {
+ try {
+ return str_to_datetime(value);
+ } catch(e) {}
+ try {
+ return str_to_date(value);
+ } catch(e) {}
+ try {
+ return str_to_time(value);
+ } catch(e) {}
+ throw new Error(_.str.sprintf(_t("'%s' is not a correct date, datetime nor time"), value));
+}
+
+function auto_date_to_str (value, type) {
+ switch(type) {
+ case 'datetime':
+ return datetime_to_str(value);
+ case 'date':
+ return date_to_str(value);
+ case 'time':
+ return time_to_str(value);
+ default:
+ throw new Error(_.str.sprintf(_t("'%s' is not convertible to date, datetime nor time"), type));
+ }
+}
+
+/**
+ * Convert Python strftime to escaped moment.js format.
+ *
+ * @param {String} value original format
+ */
+function strftime_to_moment_format (value) {
+ if (_normalize_format_cache[value] === undefined) {
+ var isletter = /[a-zA-Z]/,
+ output = [],
+ inToken = false;
+
+ for (var index=0; index < value.length; ++index) {
+ var character = value[index];
+ if (character === '%' && !inToken) {
+ inToken = true;
+ continue;
+ }
+ if (isletter.test(character)) {
+ if (inToken && normalize_format_table[character] !== undefined) {
+ character = normalize_format_table[character];
+ } else {
+ character = '[' + character + ']'; // moment.js escape
+ }
+ }
+ output.push(character);
+ inToken = false;
+ }
+ _normalize_format_cache[value] = output.join('');
+ }
+ return _normalize_format_cache[value];
+}
+
+var _normalize_format_cache = {};
+var normalize_format_table = {
+ // Python strftime to moment.js conversion table
+ // See openerp/addons/base/res/res_lang_view.xml
+ // for details about supported directives
+ 'a': 'ddd',
+ 'A': 'dddd',
+ 'b': 'MMM',
+ 'B': 'MMMM',
+ 'd': 'DD',
+ 'H': 'HH',
+ 'I': 'hh',
+ 'j': 'DDDD',
+ 'm': 'MM',
+ 'M': 'mm',
+ 'p': 'A',
+ 'S': 'ss',
+ 'U': 'ww',
+ 'W': 'WW',
+ 'w': 'd',
+ 'y': 'YY',
+ 'Y': 'YYYY',
+ // unsupported directives
+ 'c': 'ddd MMM D HH:mm:ss YYYY',
+ 'x': 'MM/DD/YY',
+ 'X': 'HH:mm:ss'
+};
+
+
+
+return {
+ date_to_utc: date_to_utc,
+ str_to_datetime: str_to_datetime,
+ str_to_date: str_to_date,
+ str_to_time: str_to_time,
+ datetime_to_str: datetime_to_str,
+ date_to_str: date_to_str,
+ time_to_str: time_to_str,
+ auto_str_to_date: auto_str_to_date,
+ auto_date_to_str: auto_date_to_str,
+ strftime_to_moment_format: strftime_to_moment_format,
+};
+
+});
+
diff --git a/addons/web/static/src/js/framework/translation.js b/addons/web/static/src/js/framework/translation.js
new file mode 100644
index 00000000000..6f44a57b275
--- /dev/null
+++ b/addons/web/static/src/js/framework/translation.js
@@ -0,0 +1,98 @@
+
+odoo.define('web.translation', ['web.Class', 'qweb'], function (require) {
+"use strict";
+
+var Class = require('web.Class');
+
+var qweb = require('qweb');
+
+
+var TranslationDataBase = Class.extend(/** @lends instance.TranslationDataBase# */{
+ init: function() {
+ this.db = {};
+ this.parameters = {"direction": 'ltr',
+ "date_format": '%m/%d/%Y',
+ "time_format": '%H:%M:%S',
+ "grouping": [],
+ "decimal_point": ".",
+ "thousands_sep": ","};
+ },
+ set_bundle: function(translation_bundle) {
+ var self = this;
+ this.db = {};
+ var modules = _.keys(translation_bundle.modules);
+ modules.sort();
+ if (_.include(modules, "web")) {
+ modules = ["web"].concat(_.without(modules, "web"));
+ }
+ _.each(modules, function(name) {
+ self.add_module_translation(translation_bundle.modules[name]);
+ });
+ if (translation_bundle.lang_parameters) {
+ this.parameters = translation_bundle.lang_parameters;
+ if (typeof(py) !== "undefined") {
+ this.parameters.grouping = py.eval(this.parameters.grouping);
+ }
+ }
+ },
+ add_module_translation: function(mod) {
+ var self = this;
+ _.each(mod.messages, function(message) {
+ self.db[message.id] = message.string;
+ });
+ },
+ build_translation_function: function() {
+ var self = this;
+ var fcnt = function(str) {
+ var tmp = self.get(str);
+ return tmp === undefined ? str : tmp;
+ };
+ fcnt.database = this;
+ return fcnt;
+ },
+ get: function(key) {
+ return this.db[key];
+ },
+ /**
+ Loads the translations from an OpenERP server.
+
+ @param {openerp.Session} session The session object to contact the server.
+ @param {Array} [modules] The list of modules to load the translation. If not specified,
+ it will default to all the modules installed in the current database.
+ @param {Object} [lang] lang The language. If not specified it will default to the language
+ of the current user.
+ @returns {jQuery.Deferred}
+ */
+ load_translations: function(session, modules, lang) {
+ var self = this;
+ return session.rpc('/web/webclient/translations', {
+ "mods": modules || null,
+ "lang": lang || null
+ }).done(function(trans) {
+ self.set_bundle(trans);
+ });
+ }
+});
+
+var _t = new TranslationDataBase().build_translation_function();
+var _lt = function (s) {
+ return {toString: function () { return _t(s); }};
+};
+
+// provide timeago.js with our own translator method
+$.timeago.settings.translator = _t;
+
+qweb.default_dict = {
+ '_' : _,
+ 'JSON': JSON,
+ '_t' : _t,
+};
+
+return {
+ _t: _t,
+ _lt: _lt,
+ TranslationDataBase: TranslationDataBase,
+};
+
+});
+
diff --git a/addons/web/static/src/js/framework/utils.js b/addons/web/static/src/js/framework/utils.js
new file mode 100644
index 00000000000..d412a17bc9a
--- /dev/null
+++ b/addons/web/static/src/js/framework/utils.js
@@ -0,0 +1,383 @@
+odoo.define('web.utils', ['web.Class', 'web.translation'], function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var translation = require('web.translation');
+
+var _t = translation._t;
+
+/**
+ * computes (Math.floor(a/b), a%b and passes that to the callback.
+ *
+ * returns the callback's result
+ */
+function divmod (a, b, fn) {
+ var mod = a%b;
+ // in python, sign(a % b) === sign(b). Not in JS. If wrong side, add a
+ // round of b
+ if (mod > 0 && b < 0 || mod < 0 && b > 0) {
+ mod += b;
+ }
+ return fn(Math.floor(a/b), mod);
+}
+
+/**
+ * Passes the fractional and integer parts of x to the callback, returns
+ * the callback's result
+ */
+function modf (x, fn) {
+ var mod = x%1;
+ if (mod < 0) {
+ mod += 1;
+ }
+ return fn(mod, Math.floor(x));
+}
+
+/*
+ * Left-pad provided arg 1 with zeroes until reaching size provided by second
+ * argument.
+ *
+ * @param {Number|String} str value to pad
+ * @param {Number} size size to reach on the final padded value
+ * @returns {String} padded string
+ */
+function lpad (str, size) {
+ str = "" + str;
+ return new Array(size - str.length + 1).join('0') + str;
+}
+
+function rpad (str, size) {
+ str = "" + str;
+ return str + new Array(size - str.length + 1).join('0');
+}
+
+var id = -1;
+
+function generate_id () {
+ return ++id;
+}
+
+/**
+ * Intersperses ``separator`` in ``str`` at the positions indicated by
+ * ``indices``.
+ *
+ * ``indices`` is an array of relative offsets (from the previous insertion
+ * position, starting from the end of the string) at which to insert
+ * ``separator``.
+ *
+ * There are two special values:
+ *
+ * ``-1``
+ * indicates the insertion should end now
+ * ``0``
+ * indicates that the previous section pattern should be repeated (until all
+ * of ``str`` is consumed)
+ *
+ * @param {String} str
+ * @param {Array<Number>} indices
+ * @param {String} separator
+ * @returns {String}
+ */
+function intersperse (str, indices, separator) {
+ separator = separator || '';
+ var result = [], last = str.length;
+
+ for(var i=0; i<indices.length; ++i) {
+ var section = indices[i];
+ if (section === -1 || last <= 0) {
+ // Done with string, or -1 (stops formatting string)
+ break;
+ } else if(section === 0 && i === 0) {
+ // repeats previous section, which there is none => stop
+ break;
+ } else if (section === 0) {
+ // repeat previous section forever
+ //noinspection AssignmentToForLoopParameterJS
+ section = indices[--i];
+ }
+ result.push(str.substring(last-section, last));
+ last -= section;
+ }
+
+ var s = str.substring(0, last);
+ if (s) { result.push(s); }
+ return result.reverse().join(separator);
+}
+
+function xml_to_json (node, strip_whitespace) {
+ switch (node.nodeType) {
+ case 9:
+ return xml_to_json(node.documentElement, strip_whitespace);
+ case 3:
+ case 4:
+ return (strip_whitespace && node.data.trim() === '') ? undefined : node.data;
+ case 1:
+ var attrs = $(node).getAttributes();
+ _.each(['domain', 'filter_domain', 'context', 'default_get'], function(key) {
+ if (attrs[key]) {
+ try {
+ attrs[key] = JSON.parse(attrs[key]);
+ } catch(e) { }
+ }
+ });
+ return {
+ tag: node.tagName.toLowerCase(),
+ attrs: attrs,
+ children: _.compact(_.map(node.childNodes, function(node) {
+ return xml_to_json(node, strip_whitespace);
+ })),
+ };
+ }
+}
+
+function json_node_to_xml (node, human_readable, indent) {
+ // For debugging purpose, this function will convert a json node back to xml
+ indent = indent || 0;
+ var sindent = (human_readable ? (new Array(indent + 1).join('\t')) : ''),
+ r = sindent + '<' + node.tag,
+ cr = human_readable ? '\n' : '';
+
+ if (typeof(node) === 'string') {
+ return sindent + node;
+ } else if (typeof(node.tag) !== 'string' || !node.children instanceof Array || !node.attrs instanceof Object) {
+ throw new Error(
+ _.str.sprintf(_t("Node [%s] is not a JSONified XML node"),
+ JSON.stringify(node)));
+ }
+ for (var attr in node.attrs) {
+ var vattr = node.attrs[attr];
+ if (typeof(vattr) !== 'string') {
+ // domains, ...
+ vattr = JSON.stringify(vattr);
+ }
+ vattr = vattr.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+ if (human_readable) {
+ vattr = vattr.replace(/&quot;/g, "'");
+ }
+ r += ' ' + attr + '="' + vattr + '"';
+ }
+ if (node.children && node.children.length) {
+ r += '>' + cr;
+ var childs = [];
+ for (var i = 0, ii = node.children.length; i < ii; i++) {
+ childs.push(json_node_to_xml(node.children[i], human_readable, indent + 1));
+ }
+ r += childs.join(cr);
+ r += cr + sindent + '</' + node.tag + '>';
+ return r;
+ } else {
+ return r + '/>';
+ }
+}
+
+function xml_to_str (node) {
+ var str = "";
+ if (window.XMLSerializer) {
+ str = (new XMLSerializer()).serializeToString(node);
+ } else if (window.ActiveXObject) {
+ str = node.xml;
+ } else {
+ throw new Error(_t("Could not serialize XML"));
+ }
+ // Browsers won't deal with self closing tags except void elements:
+ // http://www.w3.org/TR/html-markup/syntax.html
+ var void_elements = 'area base br col command embed hr img input keygen link meta param source track wbr'.split(' ');
+
+ // The following regex is a bit naive but it's ok for the xmlserializer output
+ str = str.replace(/<([a-z]+)([^<>]*)\s*\/\s*>/g, function(match, tag, attrs) {
+ if (void_elements.indexOf(tag) < 0) {
+ return "<" + tag + attrs + "></" + tag + ">";
+ } else {
+ return match;
+ }
+ });
+ return str;
+}
+
+function Mutex () {
+ this.def = $.Deferred().resolve();
+}
+
+Mutex.prototype.exec = function (action) {
+ var current = this.def;
+ var next = this.def = $.Deferred();
+ return current.then(function() {
+ return $.when(action()).always(function() {
+ next.resolve();
+ });
+ });
+};
+
+function get_cookie (c_name) {
+ var cookies = document.cookie ? document.cookie.split('; ') : [];
+ for (var i = 0, l = cookies.length; i < l; i++) {
+ var parts = cookies[i].split('=');
+ var name = parts.shift();
+ var cookie = parts.join('=');
+
+ if (c_name && c_name === name) {
+ return cookie;
+ }
+ }
+ return "";
+}
+
+/**
+ * Insert "thousands" separators in the provided number (which is actually
+ * a string)
+ *
+ * @param {String} num
+ * @returns {String}
+ */
+function insert_thousand_seps (num) {
+ var negative = num[0] === '-';
+ num = (negative ? num.slice(1) : num);
+ return (negative ? '-' : '') + intersperse(
+ num, _t.database.parameters.grouping, _t.database.parameters.thousands_sep);
+}
+
+function is_bin_size (v) {
+ return (/^\d+(\.\d*)? \w+$/).test(v);
+}
+
+/**
+ * Check with a scary heuristic if the value is a bin_size or not.
+ * If not, compute an approximate size out of the base64 encoded string.
+ *
+ * @param {String} value original format
+ */
+function binary_to_binsize (value) {
+ if (!value) {
+ return human_size(0);
+ }
+ if (value.substr(0, 10).indexOf(' ') == -1) {
+ // Computing approximate size out of base64 encoded string
+ // http://en.wikipedia.org/wiki/Base64#MIME
+ return human_size(value.length / 1.37);
+ } else {
+ // already bin_size
+ return value;
+ }
+}
+
+/**
+ * Returns a human readable size
+ *
+ * @param {Number} numner of bytes
+ */
+function human_size (size) {
+ var units = _t("Bytes,Kb,Mb,Gb,Tb,Pb,Eb,Zb,Yb").split(',');
+ var i = 0;
+ while (size >= 1024) {
+ size /= 1024;
+ ++i;
+ }
+ return size.toFixed(2) + ' ' + units[i];
+}
+
+/**
+ * performs a half up rounding with arbitrary precision, correcting for float loss of precision
+ * See the corresponding float_round() in server/tools/float_utils.py for more info
+ * @param {Number} the value to be rounded
+ * @param {Number} a precision parameter. eg: 0.01 rounds to two digits.
+ */
+function round_precision (value, precision) {
+ if (!value) {
+ return 0;
+ } else if (!precision || precision < 0) {
+ precision = 1;
+ }
+ var normalized_value = value / precision;
+ var epsilon_magnitude = Math.log(Math.abs(normalized_value))/Math.log(2);
+ var epsilon = Math.pow(2, epsilon_magnitude - 53);
+ normalized_value += normalized_value >= 0 ? epsilon : -epsilon;
+ var rounded_value = Math.round(normalized_value);
+ return rounded_value * precision;
+}
+
+/**
+ * performs a half up rounding with a fixed amount of decimals, correcting for float loss of precision
+ * See the corresponding float_round() in server/tools/float_utils.py for more info
+ * @param {Number} the value to be rounded
+ * @param {Number} the number of decimals. eg: round_decimals(3.141592,2) -> 3.14
+ */
+function round_decimals (value, decimals) {
+ return round_precision(value, Math.pow(10,-decimals));
+}
+
+function float_is_zero (value, decimals) {
+ var epsilon = Math.pow(10, -decimals);
+ return Math.abs(round_precision(value, epsilon)) < epsilon;
+};
+
+function assert (bool) {
+ if (!bool) {
+ throw new Error("AssertionError");
+ }
+}
+
+var DropMisordered = Class.extend({
+ /**
+ * @constructs instance.web.DropMisordered
+ * @extends instance.web.Class
+ *
+ * @param {Boolean} [failMisordered=false] whether mis-ordered responses should be failed or just ignored
+ */
+ init: function (failMisordered) {
+ // local sequence number, for requests sent
+ this.lsn = 0;
+ // remote sequence number, seqnum of last received request
+ this.rsn = -1;
+ this.failMisordered = failMisordered || false;
+ },
+ /**
+ * Adds a deferred (usually an async request) to the sequencer
+ *
+ * @param {$.Deferred} deferred to ensure add
+ * @returns {$.Deferred}
+ */
+ add: function (deferred) {
+ var res = $.Deferred();
+
+ var self = this, seq = this.lsn++;
+ deferred.done(function () {
+ if (seq > self.rsn) {
+ self.rsn = seq;
+ res.resolve.apply(res, arguments);
+ } else if (self.failMisordered) {
+ res.reject();
+ }
+ }).fail(function () {
+ res.reject.apply(res, arguments);
+ });
+
+ return res.promise();
+ },
+});
+
+
+return {
+ divmod: divmod,
+ modf: modf,
+ lpad: lpad,
+ rpad: rpad,
+ generate_id: generate_id,
+ intersperse: intersperse,
+ xml_to_json: xml_to_json,
+ json_node_to_xml: json_node_to_xml,
+ xml_to_str: xml_to_str,
+ Mutex: Mutex,
+ get_cookie: get_cookie,
+ insert_thousand_seps: insert_thousand_seps,
+ is_bin_size: is_bin_size,
+ binary_to_binsize: binary_to_binsize,
+ human_size: human_size,
+ round_precision: round_precision,
+ round_decimals: round_decimals,
+ float_is_zero: float_is_zero,
+ assert: assert,
+ DropMisordered: DropMisordered,
+};
+
+});
+
diff --git a/addons/web/static/src/js/framework/view.js b/addons/web/static/src/js/framework/view.js
new file mode 100644
index 00000000000..c315e53d710
--- /dev/null
+++ b/addons/web/static/src/js/framework/view.js
@@ -0,0 +1,239 @@
+odoo.define('web.View', ['web.core', 'web.data', 'web.pyeval', 'web.Widget'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var data = require('web.data');
+var pyeval = require('web.pyeval');
+var Widget = require('web.Widget');
+
+var View = Widget.extend({
+ // name displayed in view switchers
+ display_name: '',
+ /**
+ * Define a view type for each view to allow automatic call to fields_view_get.
+ */
+ view_type: undefined,
+ init: function(parent, dataset, view_id, options) {
+ this._super(parent);
+ this.ViewManager = parent;
+ this.dataset = dataset;
+ this.view_id = view_id;
+ this.$buttons = options && options.$buttons;
+ this.set_default_options(options);
+ },
+ start: function () {
+ return this.load_view();
+ },
+ load_view: function() {
+ var self = this;
+ var view_loaded_def;
+ if (this.embedded_view) {
+ view_loaded_def = $.Deferred();
+ $.async_when().done(function() {
+ view_loaded_def.resolve(self.embedded_view);
+ });
+ } else {
+ if (! this.view_type)
+ console.warn("view_type is not defined", this);
+ view_loaded_def = this.dataset._model.fields_view_get({
+ "view_id": this.view_id,
+ "view_type": this.view_type,
+ "toolbar": !!this.options.$sidebar,
+ "context": this.dataset.get_context(),
+ });
+ }
+ return this.alive(view_loaded_def).then(function(r) {
+ self.fields_view = r;
+ // add css classes that reflect the (absence of) access rights
+ self.$el.addClass('oe_view')
+ .toggleClass('oe_cannot_create', !self.is_action_enabled('create'))
+ .toggleClass('oe_cannot_edit', !self.is_action_enabled('edit'))
+ .toggleClass('oe_cannot_delete', !self.is_action_enabled('delete'));
+ return $.when(self.view_loading(r)).then(function() {
+ self.trigger('view_loaded', r);
+ });
+ });
+ },
+ view_loading: function(r) {
+ },
+ set_default_options: function(options) {
+ this.options = options || {};
+ _.defaults(this.options, {
+ // All possible views options should be defaulted here
+ $sidebar: null,
+ sidebar_id: null,
+ action: null,
+ action_views_ids: {}
+ });
+ },
+ /**
+ * Fetches and executes the action identified by ``action_data``.
+ *
+ * @param {Object} action_data the action descriptor data
+ * @param {String} action_data.name the action name, used to uniquely identify the action to find and execute it
+ * @param {String} [action_data.special=null] special action handlers (currently: only ``'cancel'``)
+ * @param {String} [action_data.type='workflow'] the action type, if present, one of ``'object'``, ``'action'`` or ``'workflow'``
+ * @param {Object} [action_data.context=null] additional action context, to add to the current context
+ * @param {instance.web.DataSet} dataset a dataset object used to communicate with the server
+ * @param {Object} [record_id] the identifier of the object on which the action is to be applied
+ * @param {Function} on_closed callback to execute when dialog is closed or when the action does not generate any result (no new action)
+ */
+ do_execute_action: function (action_data, dataset, record_id, on_closed) {
+ var self = this;
+ var result_handler = function () {
+ if (on_closed) { on_closed.apply(null, arguments); }
+ if (self.getParent() && self.getParent().on_action_executed) {
+ return self.getParent().on_action_executed.apply(null, arguments);
+ }
+ };
+ var context = new data.CompoundContext(dataset.get_context(), action_data.context || {});
+
+ // response handler
+ var handler = function (action) {
+ if (action && action.constructor == Object) {
+ // filter out context keys that are specific to the current action.
+ // Wrong default_* and search_default_* values will no give the expected result
+ // Wrong group_by values will simply fail and forbid rendering of the destination view
+ var ncontext = new data.CompoundContext(
+ _.object(_.reject(_.pairs(dataset.get_context().eval()), function(pair) {
+ return pair[0].match('^(?:(?:default_|search_default_).+|.+_view_ref|group_by|group_by_no_leaf|active_id|active_ids)$') !== null;
+ }))
+ );
+ ncontext.add(action_data.context || {});
+ ncontext.add({active_model: dataset.model});
+ if (record_id) {
+ ncontext.add({
+ active_id: record_id,
+ active_ids: [record_id],
+ });
+ }
+ ncontext.add(action.context || {});
+ action.context = ncontext;
+ return self.do_action(action, {
+ on_close: result_handler,
+ });
+ } else {
+ self.do_action({"type":"ir.actions.act_window_close"});
+ return result_handler();
+ }
+ };
+
+ if (action_data.special === 'cancel') {
+ return handler({"type":"ir.actions.act_window_close"});
+ } else if (action_data.type=="object") {
+ var args = [[record_id]];
+ if (action_data.args) {
+ try {
+ // Warning: quotes and double quotes problem due to json and xml clash
+ // Maybe we should force escaping in xml or do a better parse of the args array
+ var additional_args = JSON.parse(action_data.args.replace(/'/g, '"'));
+ args = args.concat(additional_args);
+ } catch(e) {
+ console.error("Could not JSON.parse arguments", action_data.args);
+ }
+ }
+ args.push(context);
+ return dataset.call_button(action_data.name, args).then(handler).then(function () {
+ core.bus.trigger('do_reload_needaction');
+ });
+ } else if (action_data.type=="action") {
+ return this.rpc('/web/action/load', {
+ action_id: action_data.name,
+ context: _.extend(pyeval.eval('context', context), {'active_model': dataset.model, 'active_ids': dataset.ids, 'active_id': record_id}),
+ do_not_eval: true
+ }).then(handler);
+ } else {
+ return dataset.exec_workflow(record_id, action_data.name).then(handler);
+ }
+ },
+ /**
+ * Directly set a view to use instead of calling fields_view_get. This method must
+ * be called before start(). When an embedded view is set, underlying implementations
+ * of instance.web.View must use the provided view instead of any other one.
+ *
+ * @param embedded_view A view.
+ */
+ set_embedded_view: function(embedded_view) {
+ this.embedded_view = embedded_view;
+ },
+ do_show: function () {
+ var self = this;
+ if (this.$buttons) {
+ this.$buttons.show();
+ }
+ this.$el.show();
+ setTimeout(function () {
+ self.$el.parent().addClass('in');
+ }, 0);
+
+ core.bus.trigger('view_shown', this);
+ },
+ do_hide: function () {
+ if (this.$buttons) {
+ this.$buttons.hide();
+ }
+ this.$el.parent().removeClass('in');
+ this.$el.hide();
+ },
+ is_active: function () {
+ return this.ViewManager.active_view.controller === this;
+ }, /**
+ * Wraps fn to only call it if the current view is the active one. If the
+ * current view is not active, doesn't call fn.
+ *
+ * fn can not return anything, as a non-call to fn can't return anything
+ * either
+ *
+ * @param {Function} fn function to wrap in the active guard
+ */
+ guard_active: function (fn) {
+ var self = this;
+ return function () {
+ if (self.is_active()) {
+ fn.apply(self, arguments);
+ }
+ };
+ },
+ do_push_state: function(state) {
+ if (this.getParent() && this.getParent().do_push_state) {
+ this.getParent().do_push_state(state);
+ }
+ },
+ do_load_state: function (state, warm) {
+
+ },
+ /**
+ * Switches to a specific view type
+ */
+ do_switch_view: function() {
+ this.trigger.apply(this, ['switch_mode'].concat(_.toArray(arguments)));
+ },
+ do_search: function(domain, context, group_by) {
+ },
+ sidebar_eval_context: function () {
+ return $.when({});
+ },
+ /**
+ * Asks the view to reload itself, if the reloading is asynchronous should
+ * return a {$.Deferred} indicating when the reloading is done.
+ */
+ reload: function () {
+ return $.when();
+ },
+ /**
+ * Return whether the user can perform the action ('create', 'edit', 'delete') in this view.
+ * An action is disabled by setting the corresponding attribute in the view's main element,
+ * like: <form string="" create="false" edit="false" delete="false">
+ */
+ is_action_enabled: function(action) {
+ var attrs = this.fields_view.arch.attrs;
+ return (action in attrs) ? JSON.parse(attrs[action]) : true;
+ },
+ get_context: function () {
+ return {};
+ },
+});
+
+return View;
+
+});
diff --git a/addons/web/static/src/js/framework/widget.js b/addons/web/static/src/js/framework/widget.js
new file mode 100644
index 00000000000..6d691f812e0
--- /dev/null
+++ b/addons/web/static/src/js/framework/widget.js
@@ -0,0 +1,367 @@
+odoo.define('web.Widget', ['web.core', 'web.session'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var session = require('web.session');
+
+var qweb = core.qweb;
+var mixins = core.mixins;
+
+/**
+ * Base class for all visual components. Provides a lot of functionalities helpful
+ * for the management of a part of the DOM.
+ *
+ * Widget handles:
+ * - Rendering with QWeb.
+ * - Life-cycle management and parenting (when a parent is destroyed, all its children are
+ * destroyed too).
+ * - Insertion in DOM.
+ *
+ * Guide to create implementations of the Widget class:
+ * ==============================================
+ *
+ * Here is a sample child class:
+ *
+ * MyWidget = openerp.base.Widget.extend({
+ * // the name of the QWeb template to use for rendering
+ * template: "MyQWebTemplate",
+ *
+ * init: function(parent) {
+ * this._super(parent);
+ * // stuff that you want to init before the rendering
+ * },
+ * start: function() {
+ * // stuff you want to make after the rendering, `this.$el` holds a correct value
+ * this.$el.find(".my_button").click(/* an example of event binding * /);
+ *
+ * // if you have some asynchronous operations, it's a good idea to return
+ * // a promise in start()
+ * var promise = this.rpc(...);
+ * return promise;
+ * }
+ * });
+ *
+ * Now this class can simply be used with the following syntax:
+ *
+ * var my_widget = new MyWidget(this);
+ * my_widget.appendTo($(".some-div"));
+ *
+ * With these two lines, the MyWidget instance was inited, rendered, it was inserted into the
+ * DOM inside the ".some-div" div and its events were binded.
+ *
+ * And of course, when you don't need that widget anymore, just do:
+ *
+ * my_widget.destroy();
+ *
+ * That will kill the widget in a clean way and erase its content from the dom.
+ */
+
+
+var Widget = core.Class.extend(mixins.PropertiesMixin, {
+ // Backbone-ish API
+ tagName: 'div',
+ id: null,
+ className: null,
+ attributes: {},
+ events: {},
+ /**
+ * The name of the QWeb template that will be used for rendering. Must be
+ * redefined in subclasses or the default render() method can not be used.
+ *
+ * @type string
+ */
+ template: null,
+ /**
+ * Constructs the widget and sets its parent if a parent is given.
+ *
+ * @constructs openerp.Widget
+ *
+ * @param {openerp.Widget} parent Binds the current instance to the given Widget instance.
+ * When that widget is destroyed by calling destroy(), the current instance will be
+ * destroyed too. Can be null.
+ */
+ init: function(parent) {
+ mixins.PropertiesMixin.init.call(this);
+ this.setParent(parent);
+ // Bind on_/do_* methods to this
+ // We might remove this automatic binding in the future
+ for (var name in this) {
+ if(typeof(this[name]) == "function") {
+ if((/^on_|^do_/).test(name)) {
+ this[name] = _.bind(this[name], this);
+ }
+ }
+ }
+ // FIXME: this should not be
+ this.setElement(this._make_descriptive());
+ },
+ /**
+ * Destroys the current widget, also destroys all its children before destroying itself.
+ */
+ destroy: function() {
+ _.each(this.getChildren(), function(el) {
+ el.destroy();
+ });
+ if(this.$el) {
+ this.$el.remove();
+ }
+ mixins.PropertiesMixin.destroy.call(this);
+ },
+ /**
+ * Renders the current widget and appends it to the given jQuery object or Widget.
+ *
+ * @param target A jQuery object or a Widget instance.
+ */
+ appendTo: function(target) {
+ var self = this;
+ return this.__widgetRenderAndInsert(function(t) {
+ self.$el.appendTo(t);
+ }, target);
+ },
+ /**
+ * Renders the current widget and prepends it to the given jQuery object or Widget.
+ *
+ * @param target A jQuery object or a Widget instance.
+ */
+ prependTo: function(target) {
+ var self = this;
+ return this.__widgetRenderAndInsert(function(t) {
+ self.$el.prependTo(t);
+ }, target);
+ },
+ /**
+ * Renders the current widget and inserts it after to the given jQuery object or Widget.
+ *
+ * @param target A jQuery object or a Widget instance.
+ */
+ insertAfter: function(target) {
+ var self = this;
+ return this.__widgetRenderAndInsert(function(t) {
+ self.$el.insertAfter(t);
+ }, target);
+ },
+ /**
+ * Renders the current widget and inserts it before to the given jQuery object or Widget.
+ *
+ * @param target A jQuery object or a Widget instance.
+ */
+ insertBefore: function(target) {
+ var self = this;
+ return this.__widgetRenderAndInsert(function(t) {
+ self.$el.insertBefore(t);
+ }, target);
+ },
+ /**
+ * Renders the current widget and replaces the given jQuery object.
+ *
+ * @param target A jQuery object or a Widget instance.
+ */
+ replace: function(target) {
+ return this.__widgetRenderAndInsert(_.bind(function(t) {
+ this.$el.replaceAll(t);
+ }, this), target);
+ },
+ __widgetRenderAndInsert: function(insertion, target) {
+ this.renderElement();
+ insertion(target);
+ return this.start();
+ },
+ /**
+ * Method called after rendering. Mostly used to bind actions, perform asynchronous
+ * calls, etc...
+ *
+ * By convention, this method should return an object that can be passed to $.when()
+ * to inform the caller when this widget has been initialized.
+ *
+ * @returns {jQuery.Deferred or any}
+ */
+ start: function() {
+ return $.when();
+ },
+ /**
+ * Renders the element. The default implementation renders the widget using QWeb,
+ * `this.template` must be defined. The context given to QWeb contains the "widget"
+ * key that references `this`.
+ */
+ renderElement: function() {
+ var $el;
+ if (this.template) {
+ $el = $(qweb.render(this.template, {widget: this}).trim());
+ } else {
+ $el = this._make_descriptive();
+ }
+ this.replaceElement($el);
+ },
+ /**
+ * Re-sets the widget's root element and replaces the old root element
+ * (if any) by the new one in the DOM.
+ *
+ * @param {HTMLElement | jQuery} $el
+ * @returns {*} this
+ */
+ replaceElement: function ($el) {
+ var $oldel = this.$el;
+ this.setElement($el);
+ if ($oldel && !$oldel.is(this.$el)) {
+ if ($oldel.length > 1) {
+ $oldel.wrapAll('<div/>');
+ $oldel.parent().replaceWith(this.$el);
+ } else {
+ $oldel.replaceWith(this.$el);
+ }
+ }
+ return this;
+ },
+ /**
+ * Re-sets the widget's root element (el/$el/$el).
+ *
+ * Includes:
+ * * re-delegating events
+ * * re-binding sub-elements
+ * * if the widget already had a root element, replacing the pre-existing
+ * element in the DOM
+ *
+ * @param {HTMLElement | jQuery} element new root element for the widget
+ * @return {*} this
+ */
+ setElement: function (element) {
+ // NB: completely useless, as WidgetMixin#init creates a $el
+ // always
+ if (this.$el) {
+ this.undelegateEvents();
+ }
+
+ this.$el = (element instanceof $) ? element : $(element);
+ this.el = this.$el[0];
+
+ this.delegateEvents();
+
+ return this;
+ },
+ /**
+ * Utility function to build small DOM elements.
+ *
+ * @param {String} tagName name of the DOM element to create
+ * @param {Object} [attributes] map of DOM attributes to set on the element
+ * @param {String} [content] HTML content to set on the element
+ * @return {Element}
+ */
+ make: function (tagName, attributes, content) {
+ var el = document.createElement(tagName);
+ if (!_.isEmpty(attributes)) {
+ $(el).attr(attributes);
+ }
+ if (content) {
+ $(el).html(content);
+ }
+ return el;
+ },
+ /**
+ * Makes a potential root element from the declarative builder of the
+ * widget
+ *
+ * @return {jQuery}
+ * @private
+ */
+ _make_descriptive: function () {
+ var attrs = _.extend({}, this.attributes || {});
+ if (this.id) { attrs.id = this.id; }
+ if (this.className) { attrs['class'] = this.className; }
+ return $(this.make(this.tagName, attrs));
+ },
+ delegateEvents: function () {
+ var events = this.events;
+ if (_.isEmpty(events)) { return; }
+
+ for(var key in events) {
+ if (!events.hasOwnProperty(key)) { continue; }
+
+ var method = this.proxy(events[key]);
+
+ var match = /^(\S+)(\s+(.*))?$/.exec(key);
+ var event = match[1];
+ var selector = match[3];
+
+ event += '.widget_events';
+ if (!selector) {
+ this.$el.on(event, method);
+ } else {
+ this.$el.on(event, selector, method);
+ }
+ }
+ },
+ undelegateEvents: function () {
+ this.$el.off('.widget_events');
+ },
+ /**
+ * Shortcut for ``this.$el.find(selector)``
+ *
+ * @param {String} selector CSS selector, rooted in $el
+ * @returns {jQuery} selector match
+ */
+ $: function(selector) {
+ if (selector === undefined)
+ return this.$el;
+ return this.$el.find(selector);
+ },
+ do_show: function () {
+ this.$el.show();
+ },
+ do_hide: function () {
+ this.$el.hide();
+ },
+ /**
+ * Proxies a method of the object, in order to keep the right ``this`` on
+ * method invocations.
+ *
+ * This method is similar to ``Function.prototype.bind`` or ``_.bind``, and
+ * even more so to ``jQuery.proxy`` with a fundamental difference: its
+ * resolution of the method being called is lazy, meaning it will use the
+ * method as it is when the proxy is called, not when the proxy is created.
+ *
+ * Other methods will fix the bound method to what it is when creating the
+ * binding/proxy, which is fine in most javascript code but problematic in
+ * OpenERP Web where developers may want to replace existing callbacks with
+ * theirs.
+ *
+ * The semantics of this precisely replace closing over the method call.
+ *
+ * @param {String|Function} method function or name of the method to invoke
+ * @returns {Function} proxied method
+ */
+ proxy: function (method) {
+ var self = this;
+ return function () {
+ var fn = (typeof method === 'string') ? self[method] : method;
+ return fn.apply(self, arguments);
+ };
+ },
+ /**
+ * Informs the action manager to do an action. This supposes that
+ * the action manager can be found amongst the ancestors of the current widget.
+ * If that's not the case this method will simply return `false`.
+ */
+ do_action: function() {
+ var parent = this.getParent();
+ if (parent) {
+ return parent.do_action.apply(parent, arguments);
+ }
+ return false;
+ },
+ do_notify: function() {
+ if (this.getParent()) {
+ return this.getParent().do_notify.apply(this,arguments);
+ }
+ return false;
+ },
+ do_warn: function(title, message) {
+ core.bus.trigger('display_notification_warning', title, message);
+ },
+ rpc: function(url, data, options) {
+ return this.alive(session.rpc(url, data, options));
+ },
+});
+
+return Widget;
+
+}); \ No newline at end of file