diff --git a/addons/web/common/http.py b/addons/web/common/http.py index 71a280f27eb..9294808fda4 100644 --- a/addons/web/common/http.py +++ b/addons/web/common/http.py @@ -405,14 +405,14 @@ def session_context(request, storage_path, session_cookie='sessionid'): #---------------------------------------------------------- addons_module = {} addons_manifest = {} -controllers_class = {} +controllers_class = [] controllers_object = {} controllers_path = {} class ControllerType(type): def __init__(cls, name, bases, attrs): super(ControllerType, cls).__init__(name, bases, attrs) - controllers_class["%s.%s" % (cls.__module__, cls.__name__)] = cls + controllers_class.append(("%s.%s" % (cls.__module__, cls.__name__), cls)) class Controller(object): __metaclass__ = ControllerType @@ -440,12 +440,12 @@ class Root(object): self.root = '/web/webclient/home' self.config = options - if self.config.backend == 'local': - conn = LocalConnector() - else: - conn = openerplib.get_connector(hostname=self.config.server_host, - port=self.config.server_port) - self.config.connector = conn + if not hasattr(self.config, 'connector'): + if self.config.backend == 'local': + self.config.connector = LocalConnector() + else: + self.config.connector = openerplib.get_connector( + hostname=self.config.server_host, port=self.config.server_port) self.session_cookie = 'sessionid' self.addons = {} @@ -526,7 +526,7 @@ class Root(object): addons_module[module] = m addons_manifest[module] = manifest statics['/%s/static' % module] = path_static - for k, v in controllers_class.items(): + for k, v in controllers_class: if k not in controllers_object: o = v() controllers_object[k] = o diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 1cdeb50cf25..9b4c8335e35 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -909,11 +909,6 @@ class Menu(openerpweb.Controller): class DataSet(openerpweb.Controller): _cp_path = "/web/dataset" - @openerpweb.jsonrequest - def fields(self, req, model): - return {'fields': req.session.model(model).fields_get(False, - req.session.eval_context(req.context))} - @openerpweb.jsonrequest def search_read(self, req, model, fields=False, offset=0, limit=False, domain=None, sort=None): return self.do_search_read(req, model, fields, offset, limit, domain, sort) @@ -949,7 +944,6 @@ class DataSet(openerpweb.Controller): if fields and fields == ['id']: # shortcut read if we only want the ids return { - 'ids': ids, 'length': length, 'records': [{'id': id} for id in ids] } @@ -957,46 +951,10 @@ class DataSet(openerpweb.Controller): records = Model.read(ids, fields or False, context) records.sort(key=lambda obj: ids.index(obj['id'])) return { - 'ids': ids, 'length': length, 'records': records } - - @openerpweb.jsonrequest - def read(self, req, model, ids, fields=False): - return self.do_search_read(req, model, ids, fields) - - @openerpweb.jsonrequest - def get(self, req, model, ids, fields=False): - return self.do_get(req, model, ids, fields) - - def do_get(self, req, model, ids, fields=False): - """ Fetches and returns the records of the model ``model`` whose ids - are in ``ids``. - - The results are in the same order as the inputs, but elements may be - missing (if there is no record left for the id) - - :param req: the JSON-RPC2 request object - :type req: openerpweb.JsonRequest - :param model: the model to read from - :type model: str - :param ids: a list of identifiers - :type ids: list - :param fields: a list of fields to fetch, ``False`` or empty to fetch - all fields in the model - :type fields: list | False - :returns: a list of records, in the same order as the list of ids - :rtype: list - """ - Model = req.session.model(model) - records = Model.read(ids, fields, req.session.eval_context(req.context)) - - record_map = dict((record['id'], record) for record in records) - - return [record_map[id] for id in ids if record_map.get(id)] - @openerpweb.jsonrequest def load(self, req, model, id, fields): m = req.session.model(model) @@ -1006,23 +964,6 @@ class DataSet(openerpweb.Controller): value = r[0] return {'value': value} - @openerpweb.jsonrequest - def create(self, req, model, data): - m = req.session.model(model) - r = m.create(data, req.session.eval_context(req.context)) - return {'result': r} - - @openerpweb.jsonrequest - def save(self, req, model, id, data): - m = req.session.model(model) - r = m.write([id], data, req.session.eval_context(req.context)) - return {'result': r} - - @openerpweb.jsonrequest - def unlink(self, req, model, ids=()): - Model = req.session.model(model) - return Model.unlink(ids, req.session.eval_context(req.context)) - def call_common(self, req, model, method, args, domain_id=None, context_id=None): has_domain = domain_id is not None and domain_id < len(args) has_context = context_id is not None and context_id < len(args) @@ -1098,19 +1039,7 @@ class DataSet(openerpweb.Controller): @openerpweb.jsonrequest def exec_workflow(self, req, model, id, signal): - r = req.session.exec_workflow(model, id, signal) - return {'result': r} - - @openerpweb.jsonrequest - def default_get(self, req, model, fields): - Model = req.session.model(model) - return Model.default_get(fields, req.session.eval_context(req.context)) - - @openerpweb.jsonrequest - def name_search(self, req, model, search_str, domain=[], context={}): - m = req.session.model(model) - r = m.name_search(search_str+'%', domain, '=ilike', context) - return {'result': r} + return req.session.exec_workflow(model, id, signal) class DataGroup(openerpweb.Controller): _cp_path = "/web/group" diff --git a/addons/web/static/lib/qunit/qunit.css b/addons/web/static/lib/qunit/qunit.css old mode 100755 new mode 100644 index bcecc4c0daf..58101ea34ce --- a/addons/web/static/lib/qunit/qunit.css +++ b/addons/web/static/lib/qunit/qunit.css @@ -1,9 +1,9 @@ /** - * QUnit v1.2.0 - A JavaScript Unit Testing Framework + * QUnit v1.4.0pre - A JavaScript Unit Testing Framework * * http://docs.jquery.com/QUnit * - * Copyright (c) 2011 John Resig, Jörn Zaefferer + * Copyright (c) 2012 John Resig, Jörn Zaefferer * Dual licensed under the MIT (MIT-LICENSE.txt) * or GPL (GPL-LICENSE.txt) licenses. */ @@ -54,6 +54,10 @@ color: #fff; } +#qunit-header label { + display: inline-block; +} + #qunit-banner { height: 5px; } @@ -223,4 +227,6 @@ position: absolute; top: -10000px; left: -10000px; + width: 1000px; + height: 1000px; } diff --git a/addons/web/static/lib/qunit/qunit.js b/addons/web/static/lib/qunit/qunit.js old mode 100755 new mode 100644 index 6d2a8a7b8ab..b71381313c7 --- a/addons/web/static/lib/qunit/qunit.js +++ b/addons/web/static/lib/qunit/qunit.js @@ -1,9 +1,9 @@ /** - * QUnit v1.2.0 - A JavaScript Unit Testing Framework + * QUnit v1.4.0pre - A JavaScript Unit Testing Framework * * http://docs.jquery.com/QUnit * - * Copyright (c) 2011 John Resig, Jörn Zaefferer + * Copyright (c) 2012 John Resig, Jörn Zaefferer * Dual licensed under the MIT (MIT-LICENSE.txt) * or GPL (GPL-LICENSE.txt) licenses. */ @@ -13,8 +13,11 @@ var defined = { setTimeout: typeof window.setTimeout !== "undefined", sessionStorage: (function() { + var x = "qunit-test-string"; try { - return !!sessionStorage.getItem; + sessionStorage.setItem(x, x); + sessionStorage.removeItem(x); + return true; } catch(e) { return false; } @@ -25,11 +28,10 @@ var testId = 0, toString = Object.prototype.toString, hasOwn = Object.prototype.hasOwnProperty; -var Test = function(name, testName, expected, testEnvironmentArg, async, callback) { +var Test = function(name, testName, expected, async, callback) { this.name = name; this.testName = testName; this.expected = expected; - this.testEnvironmentArg = testEnvironmentArg; this.async = async; this.callback = callback; this.assertions = []; @@ -62,6 +64,10 @@ Test.prototype = { runLoggingCallbacks( 'moduleStart', QUnit, { name: this.module } ); + } else if (config.autorun) { + runLoggingCallbacks( 'moduleStart', QUnit, { + name: this.module + } ); } config.current = this; @@ -69,9 +75,6 @@ Test.prototype = { setup: function() {}, teardown: function() {} }, this.moduleTestEnvironment); - if (this.testEnvironmentArg) { - extend(this.testEnvironment, this.testEnvironmentArg); - } runLoggingCallbacks( 'testStart', QUnit, { name: this.testName, @@ -274,17 +277,12 @@ var QUnit = { }, test: function(testName, expected, callback, async) { - var name = '' + testName + '', testEnvironmentArg; + var name = '' + escapeInnerText(testName) + ''; if ( arguments.length === 2 ) { callback = expected; expected = null; } - // is 2nd argument a testEnvironment? - if ( expected && typeof expected === 'object') { - testEnvironmentArg = expected; - expected = null; - } if ( config.currentModule ) { name = '' + config.currentModule + ": " + name; @@ -294,7 +292,7 @@ var QUnit = { return; } - var test = new Test(name, testName, expected, testEnvironmentArg, async, callback); + var test = new Test(name, testName, expected, async, callback); test.module = config.currentModule; test.moduleTestEnvironment = config.currentModuleTestEnviroment; test.queue(); @@ -312,6 +310,9 @@ var QUnit = { * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" ); */ ok: function(a, msg) { + if (!config.current) { + throw new Error("ok() assertion outside test context, was " + sourceFromStacktrace(2)); + } a = !!a; var details = { result: a, @@ -447,9 +448,14 @@ var QUnit = { QUnit.constructor = F; })(); -// Backwards compatibility, deprecated -QUnit.equals = QUnit.equal; -QUnit.same = QUnit.deepEqual; +// deprecated; still export them to window to provide clear error messages +// next step: remove entirely +QUnit.equals = function() { + throw new Error("QUnit.equals has been deprecated since 2009 (e88049a0), use QUnit.equal instead"); +}; +QUnit.same = function() { + throw new Error("QUnit.same has been deprecated since 2009 (e88049a0), use QUnit.deepEqual instead"); +}; // Maintain internal state var config = { @@ -513,8 +519,7 @@ if ( typeof exports === "undefined" || typeof require === "undefined" ) { extend(window, QUnit); window.QUnit = QUnit; } else { - extend(exports, QUnit); - exports.QUnit = QUnit; + module.exports = QUnit; } // define these after exposing globals to keep them in these QUnit namespace only @@ -536,6 +541,16 @@ extend(QUnit, { semaphore: 0 }); + var qunit = id( "qunit" ); + if ( qunit ) { + qunit.innerHTML = + '

' + escapeInnerText( document.title ) + '

' + + '

' + + '
' + + '

' + + '
    '; + } + var tests = id( "qunit-tests" ), banner = id( "qunit-banner" ), result = id( "qunit-testresult" ); @@ -564,15 +579,15 @@ extend(QUnit, { /** * Resets the test setup. Useful for tests that modify the DOM. * - * If jQuery is available, uses jQuery's html(), otherwise just innerHTML. + * If jQuery is available, uses jQuery's replaceWith(), otherwise use replaceChild */ reset: function() { - if ( window.jQuery ) { - jQuery( "#qunit-fixture" ).html( config.fixture ); - } else { - var main = id( 'qunit-fixture' ); - if ( main ) { - main.innerHTML = config.fixture; + var main = id( 'qunit-fixture' ); + if ( main ) { + if ( window.jQuery ) { + jQuery( main ).replaceWith( config.fixture.cloneNode(true) ); + } else { + main.parentNode.replaceChild(config.fixture.cloneNode(true), main); } } }, @@ -636,6 +651,9 @@ extend(QUnit, { }, push: function(result, actual, expected, message) { + if (!config.current) { + throw new Error("assertion outside test context, was " + sourceFromStacktrace()); + } var details = { result: result, message: message, @@ -645,21 +663,22 @@ extend(QUnit, { message = escapeInnerText(message) || (result ? "okay" : "failed"); message = '' + message + ""; - expected = escapeInnerText(QUnit.jsDump.parse(expected)); - actual = escapeInnerText(QUnit.jsDump.parse(actual)); - var output = message + ''; - if (actual != expected) { - output += ''; - output += ''; - } + var output = message; if (!result) { + expected = escapeInnerText(QUnit.jsDump.parse(expected)); + actual = escapeInnerText(QUnit.jsDump.parse(actual)); + output += '
    Expected:
    ' + expected + '
    Result:
    ' + actual + '
    Diff:
    ' + QUnit.diff(expected, actual) +'
    '; + if (actual != expected) { + output += ''; + output += ''; + } var source = sourceFromStacktrace(); if (source) { details.source = source; output += ''; } + output += "
    Expected:
    ' + expected + '
    Result:
    ' + actual + '
    Diff:
    ' + QUnit.diff(expected, actual) +'
    Source:
    ' + escapeInnerText(source) + '
    "; } - output += ""; runLoggingCallbacks( 'log', QUnit, details ); @@ -779,7 +798,7 @@ QUnit.load = function() { var main = id('qunit-fixture'); if ( main ) { - config.fixture = main.innerHTML; + config.fixture = main.cloneNode(true); } if (config.autostart) { @@ -847,6 +866,15 @@ function done() { ].join(" "); } + // clear own sessionStorage items if all tests passed + if ( config.reorder && defined.sessionStorage && config.stats.bad === 0 ) { + for (var key in sessionStorage) { + if (sessionStorage.hasOwnProperty(key) && key.indexOf("qunit-") === 0 ) { + sessionStorage.removeItem(key); + } + } + } + runLoggingCallbacks( 'done', QUnit, { failed: config.stats.bad, passed: passed, @@ -881,16 +909,21 @@ function validTest( name ) { // so far supports only Firefox, Chrome and Opera (buggy) // could be extended in the future to use something like https://github.com/csnover/TraceKit -function sourceFromStacktrace() { +function sourceFromStacktrace(offset) { + offset = offset || 3; try { throw new Error(); } catch ( e ) { if (e.stacktrace) { // Opera - return e.stacktrace.split("\n")[6]; + return e.stacktrace.split("\n")[offset + 3]; } else if (e.stack) { // Firefox, Chrome - return e.stack.split("\n")[4]; + var stack = e.stack.split("\n"); + if (/^error$/i.test(stack[0])) { + stack.shift(); + } + return stack[offset]; } else if (e.sourceURL) { // Safari, PhantomJS // TODO sourceURL points at the 'throw new Error' line above, useless @@ -989,6 +1022,7 @@ function fail(message, exception, callback) { if ( typeof console !== "undefined" && console.error && console.warn ) { console.error(message); console.error(exception); + console.error(exception.stack); console.warn(callback.toString()); } else if ( window.opera && opera.postError ) { @@ -1368,9 +1402,9 @@ QUnit.jsDump = (function() { var ret = [ ]; QUnit.jsDump.up(); for ( var key in map ) { - var val = map[key]; + var val = map[key]; ret.push( QUnit.jsDump.parse(key,'key') + ': ' + QUnit.jsDump.parse(val, undefined, stack)); - } + } QUnit.jsDump.down(); return join( '{', ret, '}' ); }, @@ -1594,4 +1628,5 @@ QUnit.diff = (function() { }; })(); -})(this); +// get at whatever the global object is, like window in browsers +})( (function() {return this}).call() ); diff --git a/addons/web/static/src/js/corelib.js b/addons/web/static/src/js/corelib.js index 5475f4ad53c..cbd28aca8a3 100644 --- a/addons/web/static/src/js/corelib.js +++ b/addons/web/static/src/js/corelib.js @@ -1390,6 +1390,7 @@ instance.web.Connection = instance.web.CallbackEnabled.extend( /** @lends instan // an invalid session or no session at all), refresh session data // (should not change, but just in case...) _.extend(self, { + session_id: result.session_id, db: result.db, username: result.login, uid: result.uid, diff --git a/addons/web/static/src/js/data.js b/addons/web/static/src/js/data.js index 4f18a4c53d9..2dc66aaa135 100644 --- a/addons/web/static/src/js/data.js +++ b/addons/web/static/src/js/data.js @@ -18,7 +18,428 @@ instance.web.serialize_sort = function (criterion) { }).join(', '); }; -instance.web.DataGroup = instance.web.OldWidget.extend( /** @lends instance.web.DataGroup# */{ +instance.web.Query = instance.web.Class.extend({ + init: function (model, fields) { + this._model = model; + this._fields = fields; + this._filter = []; + this._context = {}; + this._limit = false; + this._offset = 0; + this._order_by = []; + }, + clone: function (to_set) { + to_set = to_set || {}; + var q = new instance.web.Query(this._model, this._fields); + q._context = this._context; + q._filter = this._filter; + 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 instance.web.CompoundDomain( + q._filter, to_set.filter); + break; + case 'context': + q._context = new instance.web.CompoundContext( + q._context, to_set.context); + break; + case 'limit': + case 'offset': + case 'order_by': + q['_' + key] = to_set[key]; + } + } + return q; + }, + _execute: function () { + var self = this; + return instance.connection.rpc('/web/dataset/search_read', { + model: this._model.name, + fields: this._fields || false, + domain: this._model.domain(this._filter), + context: this._model.context(this._context), + offset: this._offset, + limit: this._limit, + sort: instance.web.serialize_sort(this._order_by) + }).pipe(function (results) { + self._count = results.length; + return results.records; + }, null); + }, + /** + * Fetches the first record matching the query, or null + * + * @returns {jQuery.Deferred} + */ + first: function () { + var self = this; + return this.clone({limit: 1})._execute().pipe(function (records) { + delete self._count; + if (records.length) { return records[0]; } + return null; + }); + }, + /** + * Fetches all records matching the query + * + * @returns {jQuery.Deferred>} + */ + all: function () { + return this._execute(); + }, + /** + * Fetches the number of records matching the query in the database + * + * @returns {jQuery.Deferred} + */ + 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} grouping + * @returns {jQuery.Deferred> | null} + */ + group_by: function (grouping) { + if (grouping === undefined) { + return null; + } + + if (!(grouping instanceof Array)) { + grouping = _.toArray(arguments); + } + if (_.isEmpty(grouping)) { return null; } + + var self = this; + return this._model.call('read_group', { + groupby: grouping, + fields: _.uniq(grouping.concat(this._fields || [])), + domain: this._model.domain(this._filter), + context: this._model.context(this._context), + offset: this._offset, + limit: this._limit, + orderby: instance.web.serialize_sort(this._order_by) || false + }).pipe(function (results) { + return _(results).map(function (result) { + return new instance.web.data.Group( + self._model.name, grouping[0], 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 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}); + } +}); + +instance.web.Model = instance.web.Class.extend(/** @lends openerp.web.Model# */{ + /** + * @constructs instance.web.Model + * @extends instance.web.Class + * + * @param {String} model_name name of the OpenERP model this object is bound to + * @param {Object} [context] + * @param {Array} [domain] + */ + init: function (model_name, context, domain) { + this.name = model_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 + * @returns {jQuery.Deferred<>} call result + */ + call: function (method, args, kwargs) { + args = args || []; + kwargs = kwargs || {}; + if (!_.isArray(args)) { + // call(method, kwargs) + kwargs = args; + args = []; + } + return instance.connection.rpc('/web/dataset/call_kw', { + model: this.name, + method: method, + args: args, + kwargs: kwargs + }); + }, + /** + * Fetches a Query instance bound to this model, for searching + * + * @param {Array} [fields] fields to ultimately fetch during the search + * @returns {openerp.web.Query} + */ + query: function (fields) { + return new instance.web.Query(this, fields); + }, + /** + * 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 instance.connection.rpc('/web/dataset/exec_workflow', { + model: this.name, + id: id, + signal: signal + }); + }, + /** + * Fetches the model's domain, combined with the provided domain if any + * + * @param {Array} [domain] to combine with the model's internal domain + * @returns 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 instance.web.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 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 instance.web.CompoundContext( + instance.connection.user_context, this._context, context || {}); + }, + /** + * Button action caller, needs to perform cleanup if an action is returned + * from the button (parsing of context and domain, and fixup of the views + * collection for act_window actions) + * + * FIXME: remove when evaluator integrated + */ + call_button: function (method, args) { + return instance.connection.rpc('/web/dataset/call_button', { + model: this.name, + method: method, + domain_id: null, + context_id: args.length - 1, + args: args || [] + }); + }, +}); + +instance.web.Traverser = instance.web.Class.extend(/** @lends openerp.web.Traverser# */{ + /** + * @constructs instance.web.Traverser + * @extends instance.web.Class + * + * @param {instance.web.Model} model instance this traverser is bound to + */ + init: function (model) { + this._model = model; + this._index = 0; + }, + + /** + * Gets and sets the current index + * + * @param {Number} [idx] + * @returns {Number} current index + */ + index: function (idx) { + if (idx) { this._index = idx; } + return this._index; + }, + /** + * Returns the model this traverser is currently bound to + * + * @returns {openerp.web.Model} + */ + model: function () { + return this._model; + }, + /** + * Fetches the size of the backing model's match + * + * @returns {Deferred} deferred count + */ + size: function () { + return this._model.query().count(); + }, + + /** + * Record at the current index for the collection, fails if there is no + * record at the current index. + * + * @returns {Deferred<>} + */ + current: function (fields) { + return this._model.query(fields).first().pipe(function (record) { + if (record == null) { + return $.Deferred() + .reject('No record at index' + this._index) + .promise(); + } + return record; + }); + }, + next: function (fields) { + var self = this; + this._index++; + return this.size().pipe(function (s) { + if (self._index >= s) { + self._index = 0; + } + return self.current(fields); + }); + }, + previous: function (fields) { + var self = this; + this._index--; + if (this._index < 0) { + return this.size().pipe(function (s) { + self._index = s-1; + return self.current(fields); + }); + } + return this.current(fields); + } + +}); + +/** + * Utility objects, should never need to be instantiated from outside of this + * module + * + * @namespace + */ +instance.web.data = { + Group: instance.web.Class.extend(/** @lends openerp.web.data.Group# */{ + /** + * @constructs instance.web.data.Group + * @extends instance.web.Class + */ + init: function (model, grouping_field, 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 aggregates = {}; + _(fixed_group).each(function (value, key) { + if (key.indexOf('__') === 0 + || key === grouping_field + || key === grouping_field + '_count') { + return; + } + aggregates[key] = value || 0; + }); + + this.model = new instance.web.Model( + model, fixed_group.__context, fixed_group.__domain); + + var group_size = fixed_group[grouping_field + '_count'] || fixed_group.__count || 0; + var leaf_group = fixed_group.__context.group_by.length === 0; + this.attributes = { + grouped_on: grouping_field, + // if terminal group (or no group) and group_by_no_leaf => use group.__count + length: group_size, + value: fixed_group[grouping_field], + // 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); + } + }) +}; + +instance.web.DataGroup = instance.web.OldWidget.extend( /** @lends openerp.web.DataGroup# */{ /** * Management interface between views and grouped collections of OpenERP * records. @@ -41,182 +462,52 @@ instance.web.DataGroup = instance.web.OldWidget.extend( /** @lends instance.web */ init: function(parent, model, domain, context, group_by, level) { this._super(parent, null); - if (group_by) { - if (group_by.length || context['group_by_no_leaf']) { - return new instance.web.ContainerDataGroup( this, model, domain, context, group_by, level); - } else { - return new instance.web.GrouplessDataGroup( this, model, domain, context, level); - } - } - - this.model = model; + this.model = new instance.web.Model(model, context, domain); + this.group_by = group_by; this.context = context; this.domain = domain; this.level = level || 0; }, - cls: 'DataGroup' -}); -instance.web.ContainerDataGroup = instance.web.DataGroup.extend( /** @lends instance.web.ContainerDataGroup# */ { - /** - * - * @constructs instance.web.ContainerDataGroup - * @extends instance.web.DataGroup - * - * @param session - * @param model - * @param domain - * @param context - * @param group_by - * @param level - */ - init: function (parent, model, domain, context, group_by, level) { - this._super(parent, model, domain, context, null, level); - - this.group_by = group_by; - }, - /** - * The format returned by ``read_group`` is absolutely dreadful: - * - * * A ``__context`` key provides future grouping levels - * * A ``__domain`` key provides the domain for the next search - * * The current grouping value is provided through the name of the - * current grouping name e.g. if currently grouping on ``user_id``, then - * the ``user_id`` value for this group will be provided through the - * ``user_id`` key. - * * Similarly, the number of items in the group (not necessarily direct) - * is provided via ``${current_field}_count`` - * * Other aggregate fields are just dumped there - * - * This function slightly improves the grouping records by: - * - * * Adding a ``grouped_on`` property providing the current grouping field - * * Adding a ``value`` and a ``length`` properties which replace the - * ``$current_field`` and ``${current_field}_count`` ones - * * Moving aggregate values into an ``aggregates`` property object - * - * Context and domain keys remain as-is, they should not be used externally - * but in case they're needed... - * - * @param {Object} group ``read_group`` record - */ - transform_group: function (group) { - var field_name = this.group_by[0]; - // 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: []}, - group); - - var aggregates = {}; - _(fixed_group).each(function (value, key) { - if (key.indexOf('__') === 0 - || key === field_name - || key === field_name + '_count') { - return; - } - aggregates[key] = value || 0; - }); - - var group_size = fixed_group[field_name + '_count'] || fixed_group.__count || 0; - var leaf_group = fixed_group.__context.group_by.length === 0; - return { - __context: fixed_group.__context, - __domain: fixed_group.__domain, - - grouped_on: field_name, - // if terminal group (or no group) and group_by_no_leaf => use group.__count - length: group_size, - value: fixed_group[field_name], - // A group is openable if it's not a leaf in group_by_no_leaf mode - openable: !(leaf_group && this.context['group_by_no_leaf']), - - aggregates: aggregates - }; - }, - fetch: function (fields) { - // internal method - var d = new $.Deferred(); - var self = this; - - this.rpc('/web/group/read', { - model: this.model, - context: this.context, - domain: this.domain, - fields: _.uniq(this.group_by.concat(fields)), - group_by_fields: this.group_by, - sort: instance.web.serialize_sort(this.sort) - }, function () { }).then(function (response) { - var data_groups = _(response).map( - _.bind(self.transform_group, self)); - self.groups = data_groups; - d.resolveWith(self, [data_groups]); - }, function () { - d.rejectWith.apply(d, [self, arguments]); - }); - return d.promise(); - }, - /** - * The items of a list have the following properties: - * - * ``length`` - * the number of records contained in the group (and all of its - * sub-groups). This does *not* provide the size of the "next level" - * of the group, unless the group is terminal (no more groups within - * it). - * ``grouped_on`` - * the name of the field this level was grouped on, this is mostly - * used for display purposes, in order to know the name of the current - * level of grouping. The ``grouped_on`` should be the same for all - * objects of the list. - * ``value`` - * the value which led to this group (this is the value all contained - * records have for the current ``grouped_on`` field name). - * ``aggregates`` - * a mapping of other aggregation fields provided by ``read_group`` - * - * @param {Array} fields the list of fields to aggregate in each group, can be empty - * @param {Function} ifGroups function executed if any group is found (DataGroup.group_by is non-null and non-empty), called with a (potentially empty) list of groups as parameters. - * @param {Function} ifRecords function executed if there is no grouping left to perform, called with a DataSet instance as parameter - */ list: function (fields, ifGroups, ifRecords) { var self = this; - this.fetch(fields).then(function (group_records) { - ifGroups(_(group_records).map(function (group) { - var child_context = _.extend({}, self.context, group.__context); + $.when(this.model.query(fields) + .order_by(this.sort) + .group_by(this.group_by)).then(function (groups) { + if (!groups) { + ifRecords(_.extend( + new instance.web.DataSetSearch( + self, self.model.name, + self.model.context(), + self.model.domain()), + {_sort: self.sort})); + return; + } + ifGroups(_(groups).map(function (group) { + var child_context = _.extend( + {}, self.model.context(), group.model.context()); return _.extend( new instance.web.DataGroup( - self, self.model, group.__domain, - child_context, child_context.group_by, + self, self.model.name, group.model.domain(), + child_context, group.model._context.group_by, self.level + 1), - group, {sort: self.sort}); + { + __context: child_context, + __domain: group.model.domain(), + grouped_on: group.get('grouped_on'), + length: group.get('length'), + value: group.get('value'), + openable: group.get('has_children'), + aggregates: group.get('aggregates') + }, {sort: self.sort}); })); }); } }); -instance.web.GrouplessDataGroup = instance.web.DataGroup.extend( /** @lends instance.web.GrouplessDataGroup# */ { - /** - * - * @constructs instance.web.GrouplessDataGroup - * @extends instance.web.DataGroup - * - * @param session - * @param model - * @param domain - * @param context - * @param level - */ - init: function (parent, model, domain, context, level) { - this._super(parent, model, domain, context, null, level); - }, - list: function (fields, ifGroups, ifRecords) { - ifRecords(_.extend( - new instance.web.DataSetSearch(this, this.model), - {domain: this.domain, context: this.context, _sort: this.sort})); - } -}); -instance.web.StaticDataGroup = instance.web.GrouplessDataGroup.extend( /** @lends instance.web.StaticDataGroup# */ { +instance.web.ContainerDataGroup = instance.web.DataGroup.extend({ }); +instance.web.GrouplessDataGroup = instance.web.DataGroup.extend({ }); + +instance.web.StaticDataGroup = instance.web.GrouplessDataGroup.extend( /** @lends openerp.web.StaticDataGroup# */ { /** * A specialization of groupless data groups, relying on a single static * dataset as its records provider. @@ -233,7 +524,7 @@ instance.web.StaticDataGroup = instance.web.GrouplessDataGroup.extend( /** @lend } }); -instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.DataSet# */{ +instance.web.DataSet = instance.web.OldWidget.extend( /** @lends openerp.web.DataSet# */{ /** * DateaManagement interface between views and the collection of selected * OpenERP records (represents the view's state?) @@ -249,6 +540,7 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D this.context = context || {}; this.index = null; this._sort = []; + this._model = new instance.web.Model(model, context); }, previous: function () { this.index -= 1; @@ -296,13 +588,11 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ read_ids: function (ids, fields, options) { - var options = options || {}; - return this.rpc('/web/dataset/get', { - model: this.model, - ids: ids, - fields: fields, - context: this.get_context(options.context) - }); + options = options || {}; + // TODO: reorder results to match ids list + return this._model.call('read', + [ids, fields || false], + {context: this._model.context(options.context)}); }, /** * Read a slice of the records represented by this DataSet, based on its @@ -315,7 +605,14 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ read_slice: function (fields, options) { - return null; + var self = this; + options = options || {}; + return this._model.query(fields) + .limit(options.limit || false) + .offset(options.offset || 0) + .all().then(function (records) { + self.ids = _(records).pluck('id'); + }); }, /** * Reads the current dataset record (from its index) @@ -325,18 +622,11 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ read_index: function (fields, options) { - var def = $.Deferred(); - if (_.isEmpty(this.ids)) { - def.reject(); - } else { - fields = fields || false; - this.read_ids([this.ids[this.index]], fields, options).then(function(records) { - def.resolve(records[0]); - }, function() { - def.reject.apply(def, arguments); - }); - } - return def.promise(); + options = options || {}; + return this.read_ids([this.ids[this.index]], fields, options).pipe(function (records) { + if (_.isEmpty(records)) { return $.Deferred().reject().promise(); } + return records[0]; + }); }, /** * Reads default values for the current model @@ -346,12 +636,9 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ default_get: function(fields, options) { - var options = options || {}; - return this.rpc('/web/dataset/default_get', { - model: this.model, - fields: fields, - context: this.get_context(options.context) - }); + options = options || {}; + return this._model.call('default_get', + [fields], {context: this._model.context(options.context)}); }, /** * Creates a new record in db @@ -362,11 +649,10 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ create: function(data, callback, error_callback) { - return this.rpc('/web/dataset/create', { - model: this.model, - data: data, - context: this.get_context() - }, callback, error_callback); + return this._model.call('create', + [data], {context: this._model.context()}) + .pipe(function (r) { return {result: r}; }) + .then(callback, error_callback); }, /** * Saves the provided data in an existing db record @@ -379,12 +665,10 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D */ write: function (id, data, options, callback, error_callback) { options = options || {}; - return this.rpc('/web/dataset/save', { - model: this.model, - id: id, - data: data, - context: this.get_context(options.context) - }, callback, error_callback); + return this._model.call('write', + [[id], data], {context: this._model.context(options.context)}) + .pipe(function (r) { return {result: r}}) + .then(callback, error_callback); }, /** * Deletes an existing record from the database @@ -394,9 +678,9 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @param {Function} error_callback function called in case of deletion error */ unlink: function(ids, callback, error_callback) { - var self = this; - return this.call_and_eval("unlink", [ids, this.get_context()], null, 1, - callback, error_callback); + return this._model.call('unlink', + [ids], {context: this._model.context()}) + .then(callback, error_callback); }, /** * Calls an arbitrary RPC method @@ -408,11 +692,7 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ call: function (method, args, callback, error_callback) { - return this.rpc('/web/dataset/call', { - model: this.model, - method: method, - args: args || [] - }, callback, error_callback); + return this._model.call(method, args).then(callback, error_callback); }, /** * Calls an arbitrary method, with more crazy @@ -431,9 +711,7 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D method: method, domain_id: domain_index == undefined ? null : domain_index, context_id: context_index == undefined ? null : context_index, - args: args || [], - // FIXME: API which does not suck for aborting requests in-flight - aborter: this + args: args || [] }, callback, error_callback); }, /** @@ -446,13 +724,8 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ call_button: function (method, args, callback, error_callback) { - return this.rpc('/web/dataset/call_button', { - model: this.model, - method: method, - domain_id: null, - context_id: args.length - 1, - args: args || [] - }, callback, error_callback); + return this._model.call_button(method, args) + .then(callback, error_callback); }, /** * Fetches the "readable name" for records, based on intrinsic rules @@ -462,7 +735,9 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ name_get: function(ids, callback) { - return this.call_and_eval('name_get', [ids, this.get_context()], null, 1, callback); + return this._model.call('name_get', + [ids], {context: this._model.context()}) + .then(callback); }, /** * @@ -474,29 +749,30 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ name_search: function (name, domain, operator, limit, callback) { - return this.call_and_eval('name_search', - [name || '', domain || false, operator || 'ilike', this.get_context(), limit || 0], - 1, 3, callback); + return this._model.call('name_search', { + name: name || '', + args: domain || false, + operator: operator || 'ilike', + context: this._model.context(), + limit: limit || 0 + }).then(callback); }, /** * @param name * @param callback */ name_create: function(name, callback) { - return this.call_and_eval('name_create', [name, this.get_context()], null, 1, callback); + return this._model.call('name_create', + [name], {context: this._model.context()}) + .then(callback); }, exec_workflow: function (id, signal, callback) { - return this.rpc('/web/dataset/exec_workflow', { - model: this.model, - id: id, - signal: signal - }, callback); + return this._model.exec_workflow(id, signal) + .pipe(function (result) { return { result: result }; }) + .then(callback); }, get_context: function(request_context) { - if (request_context) { - return new instance.web.CompoundContext(this.context, request_context); - } - return this.context; + return this._model.context(request_context); }, /** * Reads or changes sort criteria on the dataset. @@ -560,7 +836,7 @@ instance.web.DataSetStatic = instance.web.DataSet.extend({ this.set_ids(_.without.apply(null, [this.ids].concat(ids))); } }); -instance.web.DataSetSearch = instance.web.DataSet.extend(/** @lends instance.web.DataSetSearch */{ +instance.web.DataSetSearch = instance.web.DataSet.extend(/** @lends openerp.web.DataSetSearch */{ /** * @constructs instance.web.DataSetSearch * @extends instance.web.DataSet @@ -573,11 +849,9 @@ instance.web.DataSetSearch = instance.web.DataSet.extend(/** @lends instance.we init: function(parent, model, context, domain) { this._super(parent, model, context); this.domain = domain || []; - this.offset = 0; - this._length; - // subset records[offset:offset+limit] - // is it necessary ? + this._length = null; this.ids = []; + this._model = new instance.web.Model(model, context, domain); }, /** * Read a slice of the records represented by this DataSet, based on its @@ -594,32 +868,29 @@ instance.web.DataSetSearch = instance.web.DataSet.extend(/** @lends instance.we read_slice: function (fields, options) { options = options || {}; var self = this; - var offset = options.offset || 0; - return this.rpc('/web/dataset/search_read', { - model: this.model, - fields: fields || false, - domain: this.get_domain(options.domain), - context: this.get_context(options.context), - sort: this.sort(), - offset: offset, - limit: options.limit || false - }).pipe(function (result) { - self.ids = result.ids; - self.offset = offset; - self._length = result.length; - return result.records; + 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().then(function (records) { + // FIXME: not sure about that one, *could* have discarded count + q.count().then(function (count) { self._length = count; }); + self.ids = _(records).pluck('id'); }); }, get_domain: function (other_domain) { - if (other_domain) { - return new instance.web.CompoundDomain(this.domain, other_domain); - } - return this.domain; + this._model.domain(other_domain); }, unlink: function(ids, callback, error_callback) { var self = this; return this._super(ids, function(result) { - self.ids = _.without.apply(_, [self.ids].concat(ids)); + self.ids = _(self.ids).difference(ids); + if (self._length) { + self._length -= 1; + } if (this.index !== null) { self.index = self.index <= self.ids.length - 1 ? self.index : (self.ids.length > 0 ? self.ids.length -1 : 0); @@ -848,34 +1119,6 @@ instance.web.ProxyDataSet = instance.web.DataSetSearch.extend({ on_unlink: function(ids) {} }); -instance.web.Model = instance.web.CallbackEnabled.extend({ - init: function(model_name) { - this._super(); - this.model_name = model_name; - }, - rpc: function() { - var c = instance.connection; - return c.rpc.apply(c, arguments); - }, - /* - * deprecated because it 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: function (method, args, kwargs) { - return this.rpc('/web/dataset/call_kw', { - model: this.model_name, - method: method, - args: args, - kwargs: kwargs - }); - } -}); - instance.web.CompoundContext = instance.web.Class.extend({ init: function () { this.__ref = "compound_context"; @@ -921,6 +1164,46 @@ instance.web.CompoundDomain = instance.web.Class.extend({ return this.__eval_context; } }); + +instance.web.DropMisordered = instance.web.Class.extend(/** @lends openerp.web.DropMisordered# */{ + /** + * @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.then(function () { + if (seq > self.rsn) { + self.rsn = seq; + res.resolve.apply(res, arguments); + } else if (self.failMisordered) { + res.reject(); + } + }, function () { + res.reject.apply(res, arguments); + }); + + return res.promise(); + } +}); + }; // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax: diff --git a/addons/web/static/src/js/test_support.js b/addons/web/static/src/js/test_support.js new file mode 100644 index 00000000000..d46366326b7 --- /dev/null +++ b/addons/web/static/src/js/test_support.js @@ -0,0 +1,79 @@ +openerp.test_support = { + setup_connection: function (connection) { + var origin = location.protocol+"//"+location.host; + _.extend(connection, { + origin: origin, + prefix: origin, + server: origin, // keep chs happy + //openerp.web.qweb.default_dict['_s'] = this.origin; + rpc_function: connection.rpc_json, + session_id: false, + uid: false, + username: false, + user_context: {}, + db: false, + openerp_entreprise: false, +// this.module_list = openerp._modules.slice(); +// this.module_loaded = {}; +// _(this.module_list).each(function (mod) { +// self.module_loaded[mod] = true; +// }); + context: {}, + shortcuts: [], + active_id: null + }); + return connection.session_reload(); + }, + module: function (title, tested_core, nonliterals) { + var conf = QUnit.config.openerp = {}; + QUnit.module(title, { + setup: function () { + QUnit.stop(); + var oe = conf.openerp = window.openerp.init(); + window.openerp.web[tested_core](oe); + var done = openerp.test_support.setup_connection(oe.connection); + if (nonliterals) { + done = done.pipe(function () { + return oe.connection.rpc('/tests/add_nonliterals', { + domains: nonliterals.domains || [], + contexts: nonliterals.contexts || [] + }).then(function (r) { + oe.domains = r.domains; + oe.contexts = r.contexts; + }); + }); + } + done.always(QUnit.start) + .then(function () { + conf.openerp = oe; + }, function (e) { + QUnit.test(title, function () { + console.error(e); + QUnit.ok(false, 'Could not obtain a session:' + e.debug); + }); + }); + } + }); + }, + test: function (title, fn) { + var conf = QUnit.config.openerp; + QUnit.test(title, function () { + QUnit.stop(); + fn(conf.openerp); + }); + }, + expect: function (promise, fn) { + promise.always(QUnit.start) + .done(function () { QUnit.ok(false, 'RPC requests should not succeed'); }) + .fail(function (e) { + if (e.code !== 200) { + QUnit.equal(e.code, 200, 'Testing connector should raise RPC faults'); + if (typeof console !== 'undefined' && console.error) { + console.error(e.data.debug); + } + return; + } + fn(e.data.fault_code); + }) + } +}; diff --git a/addons/web/static/src/js/view_form.js b/addons/web/static/src/js/view_form.js index 4dd6191fcf0..7e5b173fd37 100644 --- a/addons/web/static/src/js/view_form.js +++ b/addons/web/static/src/js/view_form.js @@ -2291,6 +2291,7 @@ instance.web.form.FieldMany2One = instance.web.form.AbstractField.extend(_.exten this.last_search = []; this.floating = false; this.inhibit_on_change = false; + this.orderer = new openerp.web.DropMisordered(); }, start: function() { this._super(); @@ -2439,14 +2440,10 @@ instance.web.form.FieldMany2One = instance.web.form.AbstractField.extend(_.exten var search_val = request.term; var self = this; - if (this.abort_last) { - this.abort_last(); - delete this.abort_last; - } - var dataset = new instance.web.DataSetStatic(this, this.field.relation, self.build_context()); + var dataset = new instance.web.DataSet(this, this.field.relation, self.build_context()); - dataset.name_search(search_val, self.build_domain(), 'ilike', - this.limit + 1, function(data) { + this.orderer.add(dataset.name_search( + search_val, self.build_domain(), 'ilike', this.limit + 1)).then(function(data) { self.last_search = data; // possible selections for the m2o var values = _.map(data, function(x) { @@ -2485,7 +2482,6 @@ instance.web.form.FieldMany2One = instance.web.form.AbstractField.extend(_.exten response(values); }); - this.abort_last = dataset.abort_last; }, _quick_create: function(name) { var self = this; @@ -2493,15 +2489,15 @@ instance.web.form.FieldMany2One = instance.web.form.AbstractField.extend(_.exten self._search_create_popup("form", undefined, {"default_name": name}); }; if (self.get_definition_options().quick_create === undefined || self.get_definition_options().quick_create) { - var dataset = new instance.web.DataSetStatic(this, this.field.relation, self.build_context()); - dataset.name_create(name, function(data) { - self.display_value = {}; - self.display_value["" + data[0]] = data[1]; - self.set({value: data[0]}); - }).fail(function(error, event) { - event.preventDefault(); - slow_create(); - }); + new instance.web.DataSet(this, this.field.relation, self.build_context()) + .name_create(name, function(data) { + self.display_value = {}; + self.display_value["" + data[0]] = data[1]; + self.set({value: data[0]}); + }).fail(function(error, event) { + event.preventDefault(); + slow_create(); + }); } else slow_create(); }, diff --git a/addons/web/static/test/class.js b/addons/web/static/test/class.js index ed977635a5f..faad4421e31 100644 --- a/addons/web/static/test/class.js +++ b/addons/web/static/test/class.js @@ -2,8 +2,8 @@ $(document).ready(function () { var openerp; module('web-class', { setup: function () { - openerp = window.openerp.init(); - window.openerp.web.core(openerp); + openerp = window.openerp.init([]); + window.openerp.web.corelib(openerp); } }); test('Basic class creation', function () { diff --git a/addons/web/static/test/corelib.js b/addons/web/static/test/corelib.js deleted file mode 100644 index 45bfed69485..00000000000 --- a/addons/web/static/test/corelib.js +++ /dev/null @@ -1,143 +0,0 @@ - -module("Class"); - -test("base", function() { - ok(!!nova.Class, "Class does exist"); - ok(!!nova.Class.extend, "extend does exist"); - var Claz = nova.Class.extend({ - test: function() { - return "ok"; - } - }); - equal(new Claz().test(), "ok"); - var Claz2 = Claz.extend({ - test: function() { - return this._super() + "2"; - } - }); - equal(new Claz2().test(), "ok2"); -}); - -module("DestroyableMixin"); - -test("base", function() { - var Claz = nova.Class.extend(_.extend({}, nova.DestroyableMixin, {})); - var x = new Claz(); - equal(!!x.isDestroyed(), false); - x.destroy(); - equal(x.isDestroyed(), true); -}); - -module("ParentedMixin"); - -test("base", function() { - var Claz = nova.Class.extend(_.extend({}, nova.ParentedMixin, {})); - var x = new Claz(); - var y = new Claz(); - y.setParent(x); - equal(y.getParent(), x); - equal(x.getChildren()[0], y); - x.destroy(); - equal(y.isDestroyed(), true); -}); - -module("Events"); - -test("base", function() { - var x = new nova.internal.Events(); - var tmp = 0; - var fct = function() {tmp = 1;}; - x.on("test", fct); - equal(tmp, 0); - x.trigger("test"); - equal(tmp, 1); - tmp = 0; - x.off("test", fct); - x.trigger("test"); - equal(tmp, 0); -}); - -module("EventDispatcherMixin"); - -test("base", function() { - var Claz = nova.Class.extend(_.extend({}, nova.EventDispatcherMixin, {})); - var x = new Claz(); - var y = new Claz(); - var tmp = 0; - var fct = function() {tmp = 1;}; - x.on("test", y, fct); - equal(tmp, 0); - x.trigger("test"); - equal(tmp, 1); - tmp = 0; - x.off("test", y, fct); - x.trigger("test"); - equal(tmp, 0); - tmp = 0; - x.on("test", y, fct); - y.destroy(); - x.trigger("test"); - equal(tmp, 0); -}); - -module("GetterSetterMixin"); - -test("base", function() { - var Claz = nova.Class.extend(_.extend({}, nova.GetterSetterMixin, {})); - var x = new Claz(); - var y = new Claz(); - x.set({test: 1}); - equal(x.get("test"), 1); - var tmp = 0; - x.on("change:test", y, function(model, options) { - tmp = 1; - equal(options.oldValue, 1); - equal(options.newValue, 2); - equal(x.get("test"), 2); - equal(model, x); - }); - x.set({test: 2}); - equal(tmp, 1); -}); - -test("change event only when changed", function() { - var Claz = nova.Class.extend(_.extend({}, nova.GetterSetterMixin, {})); - var x = new Claz(); - var exec1 = false; - var exec2 = false; - x.on("change:test", null, function() {exec1 = true;}); - x.on("change", null, function() {exec2 = true;}); - x.set({"test": 3}); - equal(exec1, true); - equal(exec2, true); - exec1 = false; - exec2 = false; - x.set({"test": 3}); - equal(exec1, false); - equal(exec2, false); -}); - -module("Widget"); - -test("base", function() { - var Claz = nova.Widget.extend({ - renderElement: function() { - this.$element.attr("id", "testdiv"); - this.$element.html("test"); - } - }); - var x = new Claz(); - x.appendTo($("body")); - var $el = $("#testdiv"); - equal($el.length, 1); - equal($el.parents()[0], $("body")[0]); - equal($el.html(), "test"); - - var y = new Claz(x); - equal(y.getParent(), x); - - x.destroy(); - $el = $("#testdiv"); - equal($el.length, 0); -}); - diff --git a/addons/web/static/test/evals.js b/addons/web/static/test/evals.js index 9c1dcaf638f..290a009f46d 100644 --- a/addons/web/static/test/evals.js +++ b/addons/web/static/test/evals.js @@ -3,8 +3,9 @@ $(document).ready(function () { module("eval.contexts", { setup: function () { - openerp = window.openerp.init(); - window.openerp.web.core(openerp); + openerp = window.openerp.init([]); + window.openerp.web.corelib(openerp); + window.openerp.web.coresetup(openerp); } }); test('context_sequences', function () { diff --git a/addons/web/static/test/form.js b/addons/web/static/test/form.js index 8317c7bfcd2..aace926a96a 100644 --- a/addons/web/static/test/form.js +++ b/addons/web/static/test/form.js @@ -2,8 +2,9 @@ $(document).ready(function () { var openerp; module("form.widget", { setup: function () { - openerp = window.openerp.init(true); - window.openerp.web.core(openerp); + openerp = window.openerp.init([]); + window.openerp.web.corelib(openerp); + window.openerp.web.coresetup(openerp); window.openerp.web.chrome(openerp); // views loader stuff window.openerp.web.data(openerp); diff --git a/addons/web/static/test/formats.js b/addons/web/static/test/formats.js index c0d6798628d..4fe07ff0a9c 100644 --- a/addons/web/static/test/formats.js +++ b/addons/web/static/test/formats.js @@ -3,8 +3,9 @@ $(document).ready(function () { module('server-formats', { setup: function () { - openerp = window.openerp.init(); - window.openerp.web.core(openerp); + openerp = window.openerp.init([]); + window.openerp.web.corelib(openerp); + window.openerp.web.coresetup(openerp); window.openerp.web.dates(openerp); } }); @@ -40,8 +41,9 @@ $(document).ready(function () { module('web-formats', { setup: function () { - openerp = window.openerp.init(); - window.openerp.web.core(openerp); + openerp = window.openerp.init([]); + window.openerp.web.corelib(openerp); + window.openerp.web.coresetup(openerp); window.openerp.web.dates(openerp); window.openerp.web.formats(openerp); } @@ -206,8 +208,9 @@ $(document).ready(function () { }); module('custom-date-formats', { setup: function () { - openerp = window.openerp.init(); - window.openerp.web.core(openerp); + openerp = window.openerp.init([]); + window.openerp.web.corelib(openerp); + window.openerp.web.coresetup(openerp); window.openerp.web.dates(openerp); window.openerp.web.formats(openerp); } diff --git a/addons/web/static/test/fulltest.html b/addons/web/static/test/fulltest.html new file mode 100644 index 00000000000..a031325966a --- /dev/null +++ b/addons/web/static/test/fulltest.html @@ -0,0 +1,50 @@ + + + + + OpenERP + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    + OpenERP Web Test Suite: javascript to XML-RPC (excluded) +

    +

    +
    +

    +
      +
      + + + diff --git a/addons/web/static/test/fulltest/dataset.js b/addons/web/static/test/fulltest/dataset.js new file mode 100644 index 00000000000..9f7ab101869 --- /dev/null +++ b/addons/web/static/test/fulltest/dataset.js @@ -0,0 +1,241 @@ +$(document).ready(function () { + var t = window.openerp.test_support; + function context_(c) { + return _.extend({ lang: 'en_US', tz: 'UTC', uid: 87539319 }, c); + } + + t.module('Dataset shortcuts', 'data'); + t.test('read_index', function (openerp) { + var ds = new openerp.web.DataSet( + {session: openerp.connection}, 'some.model'); + ds.ids = [10, 20, 30, 40, 50]; + ds.index = 2; + t.expect(ds.read_index(['a', 'b', 'c']), function (result) { + strictEqual(result.method, 'read'); + strictEqual(result.model, 'some.model'); + + strictEqual(result.args.length, 2); + deepEqual(result.args[0], [30]); + + deepEqual(result.kwargs, { + context: context_() + }); + }); + }); + t.test('default_get', function (openerp) { + var ds = new openerp.web.DataSet( + {session: openerp.connection}, 'some.model', {foo: 'bar'}); + t.expect(ds.default_get(['a', 'b', 'c']), function (result) { + strictEqual(result.method, 'default_get'); + strictEqual(result.model, 'some.model'); + + strictEqual(result.args.length, 1); + deepEqual(result.args[0], ['a', 'b', 'c']); + + deepEqual(result.kwargs, { + context: context_({foo: 'bar'}) + }); + }); + }); + t.test('create', function (openerp) { + var ds = new openerp.web.DataSet({session: openerp.connection}, 'some.model'); + t.expect(ds.create({foo: 1, bar: 2}), function (r) { + strictEqual(r.method, 'create'); + + strictEqual(r.args.length, 1); + deepEqual(r.args[0], {foo: 1, bar: 2}); + + deepEqual(r.kwargs, { + context: context_() + }); + }); + }); + t.test('write', function (openerp) { + var ds = new openerp.web.DataSet({session: openerp.connection}, 'mod'); + t.expect(ds.write(42, {foo: 1}), function (r) { + strictEqual(r.method, 'write'); + + strictEqual(r.args.length, 2); + deepEqual(r.args[0], [42]); + deepEqual(r.args[1], {foo: 1}); + deepEqual(r.kwargs, { + context: context_() + }); + }); + // FIXME: can't run multiple sessions in the same test(), fucks everything up +// t.expect(ds.write(42, {foo: 1}, { context: {lang: 'bob'} }), function (r) { +// strictEqual(r.args.length, 3); +// strictEqual(r.args[2].lang, 'bob'); +// }); + }); + t.test('unlink', function (openerp) { + var ds = new openerp.web.DataSet({session: openerp.connection}, 'mod'); + t.expect(ds.unlink([42]), function (r) { + strictEqual(r.method, 'unlink'); + + strictEqual(r.args.length, 1); + deepEqual(r.args[0], [42]); + deepEqual(r.kwargs, { + context: context_() + }); + }); + }); + t.test('call', function (openerp) { + var ds = new openerp.web.DataSet({session: openerp.connection}, 'mod'); + t.expect(ds.call('frob', ['a', 'b', 42]), function (r) { + strictEqual(r.method, 'frob'); + + strictEqual(r.args.length, 3); + deepEqual(r.args, ['a', 'b', 42]); + + ok(_.isEmpty(r.kwargs)); + }); + }); + t.test('name_get', function (openerp) { + var ds = new openerp.web.DataSet({session: openerp.connection}, 'mod'); + t.expect(ds.name_get([1, 2], null), function (r) { + strictEqual(r.method, 'name_get'); + + strictEqual(r.args.length, 1); + deepEqual(r.args[0], [1, 2]); + deepEqual(r.kwargs, { + context: context_() + }); + }); + }); + t.test('name_search, name', function (openerp) { + var ds = new openerp.web.DataSet({session: openerp.connection}, 'mod'); + t.expect(ds.name_search('bob'), function (r) { + strictEqual(r.method, 'name_search'); + + strictEqual(r.args.length, 0); + deepEqual(r.kwargs, { + name: 'bob', + args: false, + operator: 'ilike', + context: context_(), + limit: 0 + }); + }); + }); + t.test('name_search, domain & operator', function (openerp) { + var ds = new openerp.web.DataSet({session: openerp.connection}, 'mod'); + t.expect(ds.name_search(0, [['foo', '=', 3]], 'someop'), function (r) { + strictEqual(r.method, 'name_search'); + + strictEqual(r.args.length, 0); + deepEqual(r.kwargs, { + name: '', + args: [['foo', '=', 3]], + operator: 'someop', + context: context_(), + limit: 0 + }); + }); + }); + t.test('exec_workflow', function (openerp) { + var ds = new openerp.web.DataSet({session: openerp.connection}, 'mod'); + t.expect(ds.exec_workflow(42, 'foo'), function (r) { + strictEqual(r['service'], 'object'); + strictEqual(r.method, 'exec_workflow'); + + // db, id, password, model, method, id + strictEqual(r.args.length, 6); + strictEqual(r.args[4], 'foo'); + strictEqual(r.args[5], 42); + }); + }); + + t.test('DataSetSearch#read_slice', function (openerp) { + var ds = new openerp.web.DataSetSearch({session: openerp.connection}, 'mod'); + t.expect(ds.read_slice(['foo', 'bar'], { + domain: [['foo', '>', 42], ['qux', '=', 'grault']], + context: {peewee: 'herman'}, + offset: 160, + limit: 80 + }), function (r) { + strictEqual(r.method, 'search'); + + strictEqual(r.args.length, 5); + deepEqual(r.args[0], [['foo', '>', 42], ['qux', '=', 'grault']]); + strictEqual(r.args[1], 160); + strictEqual(r.args[2], 80); + strictEqual(r.args[3], false); + strictEqual(r.args[4].peewee, 'herman'); + + ok(_.isEmpty(r.kwargs)); + }); + }); + t.test('DataSetSearch#read_slice sorted', function (openerp) { + var ds = new openerp.web.DataSetSearch({session: openerp.connection}, 'mod'); + ds.sort('foo'); + ds.sort('foo'); + ds.sort('bar'); + t.expect(ds.read_slice(['foo', 'bar'], { }), function (r) { + strictEqual(r.method, 'search'); + + strictEqual(r.args.length, 5); + deepEqual(r.args[0], []); + strictEqual(r.args[1], 0); + strictEqual(r.args[2], false); + strictEqual(r.args[3], 'bar ASC, foo DESC'); + deepEqual(r.args[4], context_()); + + ok(_.isEmpty(r.kwargs)); + }); + }); + + t.module('Nonliterals', 'data', { + domains: [ + "[('model_id', '=', parent.model)]", + "[('product_id','=',product_id)]" + ], + contexts: ['{a: b > c}'] + }); + t.test('Dataset', function (openerp) { + var ds = new openerp.web.DataSetSearch( + {session: openerp.connection}, 'mod'); + var c = new openerp.web.CompoundContext( + {a: 'foo', b: 3, c: 5}, openerp.contexts[0]); + t.expect(ds.read_slice(['foo', 'bar'], { + context: c + }), function (r) { + strictEqual(r.method, 'search'); + + deepEqual(r.args[4], context_({ + foo: false, + a: 'foo', + b: 3, + c: 5 + })); + + ok(_.isEmpty(r.kwargs)); + }); + }); + t.test('name_search', function (openerp) { + var eval_context = { + active_id: 42, + active_ids: [42], + active_model: 'mod', + parent: {model: 'qux'} + }; + var ds = new openerp.web.DataSet( + {session: openerp.connection}, 'mod', + new openerp.web.CompoundContext({}) + .set_eval_context(eval_context)); + var domain = new openerp.web.CompoundDomain(openerp.domains[0]) + .set_eval_context(eval_context); + t.expect(ds.name_search('foo', domain, 'ilike', 0), function (r) { + strictEqual(r.method, 'name_search'); + + strictEqual(r.args.length, 0); + deepEqual(r.kwargs, { + name: 'foo', + args: [['model_id', '=', 'qux']], + operator: 'ilike', + context: context_(), + limit: 0 + }); + }); + }); +}); diff --git a/addons/web/static/test/list-utils.js b/addons/web/static/test/list-utils.js index 8cc34454cd5..7890e71c6bc 100644 --- a/addons/web/static/test/list-utils.js +++ b/addons/web/static/test/list-utils.js @@ -10,7 +10,13 @@ $(document).ready(function () { }; module('list-events', { setup: function () { - openerp = window.openerp.init(); + openerp = window.openerp.init([]); + window.openerp.web.corelib(openerp); + window.openerp.web.coresetup(openerp); + window.openerp.web.chrome(openerp); + // views loader stuff + window.openerp.web.data(openerp); + window.openerp.web.views(openerp); window.openerp.web.list(openerp); } }); @@ -90,7 +96,13 @@ $(document).ready(function () { module('list-records', { setup: function () { - openerp = window.openerp.init(); + openerp = window.openerp.init([]); + window.openerp.web.corelib(openerp); + window.openerp.web.coresetup(openerp); + window.openerp.web.chrome(openerp); + // views loader stuff + window.openerp.web.data(openerp); + window.openerp.web.views(openerp); window.openerp.web.list(openerp); } }); @@ -123,7 +135,13 @@ $(document).ready(function () { module('list-collections-degenerate', { setup: function () { - openerp = window.openerp.init(); + openerp = window.openerp.init([]); + window.openerp.web.corelib(openerp); + window.openerp.web.coresetup(openerp); + window.openerp.web.chrome(openerp); + // views loader stuff + window.openerp.web.data(openerp); + window.openerp.web.views(openerp); window.openerp.web.list(openerp); } }); @@ -245,7 +263,13 @@ $(document).ready(function () { module('list-hofs', { setup: function () { - openerp = window.openerp.init(); + openerp = window.openerp.init([]); + window.openerp.web.corelib(openerp); + window.openerp.web.coresetup(openerp); + window.openerp.web.chrome(openerp); + // views loader stuff + window.openerp.web.data(openerp); + window.openerp.web.views(openerp); window.openerp.web.list(openerp); } }); diff --git a/addons/web/static/test/onchange.js b/addons/web/static/test/onchange.js deleted file mode 100644 index 8074c755bd3..00000000000 --- a/addons/web/static/test/onchange.js +++ /dev/null @@ -1,74 +0,0 @@ -$(document).ready(function () { - var openerp, - make_form = function (default_values) { - var fields = {}; - _(default_values).each(function (value, name) { - fields[name] = value instanceof Function ? value : { - get_on_change_value: function () { return value; } - }; - }); - return _.extend(new openerp.web.FormView(null, {}), - {fields: fields}); - }; - module("form.onchange", { - setup: function () { - openerp = window.openerp.init(true); - window.openerp.web.core(openerp); - window.openerp.web.chrome(openerp); - // views loader stuff - window.openerp.web.data(openerp); - window.openerp.web.views(openerp); - window.openerp.web.list(openerp); - window.openerp.web.form(openerp); - } - }); - test('Parse args-less onchange', function () { - var f = new openerp.web.FormView(null, {}); - var result = f.parse_on_change('on_change_foo()', {}); - equal(result.method, 'on_change_foo'); - deepEqual(result.args, []); - }); - test('Parse 1-arg onchange', function () { - var f = make_form({foo: 3}); - var result = f.parse_on_change('on_change_foo(foo)', {}); - equal(result.method, 'on_change_foo'); - deepEqual(result.args, [3]); - }); - test('Parse 2-args onchange', function () { - var f = make_form({foo: 3, bar: 'qux'}); - var result = f.parse_on_change('on_change_foo(bar, foo)', {}); - equal(result.method, 'on_change_foo'); - deepEqual(result.args, ['qux', 3]); - }); - test('Literal null', function () { - var f = make_form(); - var result = f.parse_on_change('on_null(None)', {}); - deepEqual(result.args, [null]); - }); - test('Literal true', function () { - var f = make_form(); - var result = f.parse_on_change('on_null(True)', {}); - deepEqual(result.args, [true]); - }); - test('Literal false', function () { - var f = make_form(); - var result = f.parse_on_change('on_null(False)', {}); - deepEqual(result.args, [false]); - }); - test('Literal string', function () { - var f = make_form(); - var result = f.parse_on_change('on_str("foo")', {}); - deepEqual(result.args, ['foo']); - var result2 = f.parse_on_change("on_str('foo')", {}); - deepEqual(result2.args, ['foo']); - }); - test('Literal number', function () { - var f = make_form(); - var result = f.parse_on_change('on_str(42)', {}); - deepEqual(result.args, [42]); - var result2 = f.parse_on_change("on_str(-25)", {}); - deepEqual(result2.args, [-25]); - var result3 = f.parse_on_change("on_str(25.02)", {}); - deepEqual(result3.args, [25.02]); - }); -}); diff --git a/addons/web/static/test/registry.js b/addons/web/static/test/registry.js index d0a1d10385e..da0cb952783 100644 --- a/addons/web/static/test/registry.js +++ b/addons/web/static/test/registry.js @@ -2,8 +2,8 @@ $(document).ready(function () { var openerp; module('Registry', { setup: function () { - openerp = window.openerp.init(true); - window.openerp.web.core(openerp); + openerp = window.openerp.init([]); + window.openerp.web.corelib(openerp); openerp.web.Foo = {}; openerp.web.Bar = {}; openerp.web.Foo2 = {}; diff --git a/addons/web/static/test/rpc.js b/addons/web/static/test/rpc.js new file mode 100644 index 00000000000..8a49a48685f --- /dev/null +++ b/addons/web/static/test/rpc.js @@ -0,0 +1,131 @@ +$(document).ready(function () { + var openerp; + + module('Misordered resolution management', { + setup: function () { + openerp = window.openerp.init([]); + window.openerp.web.corelib(openerp); + window.openerp.web.coresetup(openerp); + window.openerp.web.data(openerp); + } + }); + test('Resolve all correctly ordered, sync', function () { + var dm = new openerp.web.DropMisordered(), flag = false; + + var d1 = $.Deferred(), d2 = $.Deferred(), + r1 = dm.add(d1), r2 = dm.add(d2); + + $.when(r1, r2).done(function () { + flag = true; + }); + d1.resolve(); + d2.resolve(); + + ok(flag); + }); + test("Don't resolve mis-ordered, sync", function () { + var dm = new openerp.web.DropMisordered(), + done1 = false, done2 = false, + fail1 = false, fail2 = false; + + var d1 = $.Deferred(), d2 = $.Deferred(); + dm.add(d1).then(function () { done1 = true; }, + function () { fail1 = true; }); + dm.add(d2).then(function () { done2 = true; }, + function () { fail2 = true; }); + + d2.resolve(); + d1.resolve(); + + // d1 is in limbo + ok(!done1); + ok(!fail1); + // d2 is resolved + ok(done2); + ok(!fail2); + }); + test('Fail mis-ordered flag, sync', function () { + var dm = new openerp.web.DropMisordered(true), + done1 = false, done2 = false, + fail1 = false, fail2 = false; + + var d1 = $.Deferred(), d2 = $.Deferred(); + dm.add(d1).then(function () { done1 = true; }, + function () { fail1 = true; }); + dm.add(d2).then(function () { done2 = true; }, + function () { fail2 = true; }); + + d2.resolve(); + d1.resolve(); + + // d1 is failed + ok(!done1); + ok(fail1); + // d2 is resolved + ok(done2); + ok(!fail2); + }); + + asyncTest('Resolve all correctly ordered, sync', 1, function () { + var dm = new openerp.web.DropMisordered(); + + var d1 = $.Deferred(), d2 = $.Deferred(), + r1 = dm.add(d1), r2 = dm.add(d2); + + setTimeout(function () { d1.resolve(); }, 100); + setTimeout(function () { d2.resolve(); }, 200); + + $.when(r1, r2).done(function () { + start(); + ok(true); + }); + }); + asyncTest("Don't resolve mis-ordered, sync", 4, function () { + var dm = new openerp.web.DropMisordered(), + done1 = false, done2 = false, + fail1 = false, fail2 = false; + + var d1 = $.Deferred(), d2 = $.Deferred(); + dm.add(d1).then(function () { done1 = true; }, + function () { fail1 = true; }); + dm.add(d2).then(function () { done2 = true; }, + function () { fail2 = true; }); + + setTimeout(function () { d1.resolve(); }, 200); + setTimeout(function () { d2.resolve(); }, 100); + + setTimeout(function () { + start(); + // d1 is in limbo + ok(!done1); + ok(!fail1); + // d2 is resolved + ok(done2); + ok(!fail2); + }, 400); + }); + asyncTest('Fail mis-ordered flag, sync', 4, function () { + var dm = new openerp.web.DropMisordered(true), + done1 = false, done2 = false, + fail1 = false, fail2 = false; + + var d1 = $.Deferred(), d2 = $.Deferred(); + dm.add(d1).then(function () { done1 = true; }, + function () { fail1 = true; }); + dm.add(d2).then(function () { done2 = true; }, + function () { fail2 = true; }); + + setTimeout(function () { d1.resolve(); }, 200); + setTimeout(function () { d2.resolve(); }, 100); + + setTimeout(function () { + start(); + // d1 is failed + ok(!done1); + ok(fail1); + // d2 is resolved + ok(done2); + ok(!fail2); + }, 400); + }); +}); diff --git a/addons/web/static/test/test.html b/addons/web/static/test/test.html index 1e34c6ec933..84dd3ee7f27 100644 --- a/addons/web/static/test/test.html +++ b/addons/web/static/test/test.html @@ -26,10 +26,9 @@ - - - + + @@ -52,6 +51,6 @@ - + diff --git a/addons/web/test_support/__init__.py b/addons/web/test_support/__init__.py new file mode 100644 index 00000000000..59f6cc67ca2 --- /dev/null +++ b/addons/web/test_support/__init__.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +import xmlrpclib +from ..common.openerplib.main import Connector + +execute_map = {} + +class TestConnector(Connector): + def db_list_lang(self): + return [('en_US', u'Test Language')] + + def common_authenticate(self, db, login, password, environment): + return 87539319 + + def common_login(self, db, login, password): + return self.common_authenticate(db, login, password, {}) + + def object_execute_kw(self, db, uid, password, model, method, args, kwargs): + if model in execute_map and hasattr(execute_map[model], method): + return getattr(execute_map[model], method)(*args, **kwargs) + + raise xmlrpclib.Fault({ + 'model': model, + 'method': method, + 'args': args, + 'kwargs': kwargs + }, '') + + def send(self, service_name, method, *args): + method_name = '%s_%s' % (service_name, method) + if hasattr(self, method_name): + return getattr(self, method_name)(*args) + + raise xmlrpclib.Fault({ + 'service': service_name, + 'method': method, + 'args': args + }, '') diff --git a/addons/web/test_support/controllers.py b/addons/web/test_support/controllers.py new file mode 100644 index 00000000000..a3719070730 --- /dev/null +++ b/addons/web/test_support/controllers.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- + +from ..common import http, nonliterals +from ..controllers.main import Session + +UID = 87539319 +DB = 'test_db' +LOGIN = 'test_login' +PASSWORD = 'test_password' +CONTEXT = {'lang': 'en_US', 'tz': 'UTC', 'uid': UID} + +def bind(session): + session.bind(DB, UID, LOGIN, PASSWORD) + session.context = CONTEXT + session.build_connection().set_login_info(DB, LOGIN, PASSWORD, UID) + +class TestController(http.Controller): + _cp_path = '/tests' + + @http.jsonrequest + def add_nonliterals(self, req, domains, contexts): + return { + 'domains': [nonliterals.Domain(req.session, domain) + for domain in domains], + 'contexts': [nonliterals.Context(req.session, context) + for context in contexts] + } + +class TestSession(Session): + _cp_path = '/web/session' + + def session_info(self, req): + if not req.session._uid: + bind(req.session) + + return { + "session_id": req.session_id, + "uid": req.session._uid, + "context": CONTEXT, + "db": req.session._db, + "login": req.session._login, + "openerp_entreprise": False, + } diff --git a/addons/web_process/static/src/js/process.js b/addons/web_process/static/src/js/process.js index 85bb3447b71..272513919b4 100644 --- a/addons/web_process/static/src/js/process.js +++ b/addons/web_process/static/src/js/process.js @@ -107,7 +107,7 @@ openerp.web_process = function (instance) { if(this.process_id) return def.resolve().promise(); - this.process_dataset = new instance.web.DataSetStatic(this, "process.process", this.session.context); + this.process_dataset = new instance.web.DataSet(this, "process.process", this.session.context); this.process_dataset .call("search_by_model", [self.process_model,self.session.context]) .done(function(res) { @@ -237,7 +237,7 @@ openerp.web_process = function (instance) { }, jump_to_view: function(model, id) { var self = this; - var dataset = new instance.web.DataSetStatic(this, 'ir.values', this.session.context); + var dataset = new instance.web.DataSet(this, 'ir.values', this.session.context); dataset.call('get', ['action', 'tree_but_open',[['ir.ui.menu', id]], dataset.context], function(res) { diff --git a/doc/async.rst b/doc/async.rst new file mode 100644 index 00000000000..23b3409bd8f --- /dev/null +++ b/doc/async.rst @@ -0,0 +1,353 @@ +Don't stop the world now: asynchronous development and Javascript +================================================================= + +As a language (and runtime), javascript is fundamentally +single-threaded. This means any blocking request or computation will +blocks the whole page (and, in older browsers, the software itself +even preventing users from switching to an other tab): a javascript +environment can be seen as an event-based runloop where application +developers have no control over the runloop itself. + +As a result, performing long-running synchronous network requests or +other types of complex and expensive accesses is frowned upon and +asynchronous APIs are used instead. + +Asynchronous code rarely comes naturally, especially for developers +used to synchronous server-side code (in Python, Java or C#) where the +code will just block until the deed is gone. This is increased further +when asynchronous programming is not a first-class concept and is +instead implemented on top of callbacks-based programming, which is +the case in javascript. + +The goal of this guide is to provide some tools to deal with +asynchronous systems, and warn against systematic issues or dangers. + +Deferreds +--------- + +Deferreds are a form of `promises`_. OpenERP Web currently uses +`jQuery's deferred`_, but any `CommonJS Promises/A`_ implementation +should work. + +The core idea of deferreds is that potentially asynchronous methods +will return a :js:class:`Deferred` object instead of an arbitrary +value or (most commonly) nothing. + +This object can then be used to track the end of the asynchronous +operation by adding callbacks onto it, either success callbacks or +error callbacks. + +A great advantage of deferreds over simply passing callback functions +directly to asynchronous methods is the ability to :ref:`compose them +`. + +Using deferreds +~~~~~~~~~~~~~~~ + +`CommonJS Promises/A`_ deferreds have only one method of importance: +:js:func:`Deferred.then`. This method is used to attach new callbacks +to the deferred object. + +* the first parameter attaches a success callback, called when the + deferred object is successfully resolved and provided with the + resolved value(s) for the asynchronous operation. + +* the second parameter attaches a failure callback, called when the + deferred object is rejected and provided with rejection values + (often some sort of error message). + +Callbacks attached to deferreds are never "lost": if a callback is +attached to an already resolved or rejected deferred, the callback +will be called (or ignored) immediately. A deferred is also only ever +resolved or rejected once, and is either resolved or rejected: a given +deferred can not call a single success callback twice, or call both a +success and a failure callbacks. + +:js:func:`~Deferred.then` should be the method you'll use most often +when interacting with deferred objects (and thus asynchronous APIs). + +Building deferreds +~~~~~~~~~~~~~~~~~~ + +After using asynchronous APIs may come the time to build them: for +`mocks`_, to compose deferreds from multiple source in a complex +manner, in order to let the current operations repaint the screen or +give other events the time to unfold, ... + +This is easy using jQuery's deferred objects. + +.. note:: this section is an implementation detail of jQuery Deferred + objects, the creation of promises is not part of any + standard (even tentative) that I know of. If you are using + deferred objects which are not jQuery's, their API may (and + often will) be completely different. + +Deferreds are created by invoking their constructor [#]_ without any +argument. This creates a :js:class:`Deferred` instance object with the +following methods: + +:js:func:`Deferred.resolve` + + As its name indicates, this method moves the deferred to the + "Resolved" state. It can be provided as many arguments as + necessary, these arguments will be provided to any pending success + callback. + +:js:func:`Deferred.reject` + + Similar to :js:func:`~Deferred.resolve`, but moves the deferred to + the "Rejected" state and calls pending failure handlers. + +:js:func:`Deferred.promise` + + Creates a readonly view of the deferred object. It is generally a + good idea to return a promise view of the deferred to prevent + callers from resolving or rejecting the deferred in your stead. + +:js:func:`~Deferred.reject` and :js:func:`~Deferred.resolve` are used +to inform callers that the asynchronous operation has failed (or +succeeded). These methods should simply be called when the +asynchronous operation has ended, to notify anybody interested in its +result(s). + +.. _deferred-composition: + +Composing deferreds +~~~~~~~~~~~~~~~~~~~ + +What we've seen so far is pretty nice, but mostly doable by passing +functions to other functions (well adding functions post-facto would +probably be a chore... still, doable). + +Deferreds truly shine when code needs to compose asynchronous +operations in some way or other, as they can be used as a basis for +such composition. + +There are two main forms of compositions over deferred: multiplexing +and piping/cascading. + +Deferred multiplexing +````````````````````` + +The most common reason for multiplexing deferred is simply performing +2+ asynchronous operations and wanting to wait until all of them are +done before moving on (and executing more stuff). + +The jQuery multiplexing function for promises is :js:func:`when`. + +.. note:: the multiplexing behavior of jQuery's :js:func:`when` is an + (incompatible, mostly) extension of the behavior defined in + `CommonJS Promises/B`_. + +This function can take any number of promises [#]_ and will return a +promise. + +This returned promise will be resolved when *all* multiplexed promises +are resolved, and will be rejected as soon as one of the multiplexed +promises is rejected (it behaves like Python's ``all()``, but with +promise objects instead of boolean-ish). + +The resolved values of the various promises multiplexed via +:js:func:`when` are mapped to the arguments of :js:func:`when`'s +success callback, if they are needed. The resolved values of a promise +are at the same index in the callback's arguments as the promise in +the :js:func:`when` call so you will have: + +.. code-block:: javascript + + $.when(p0, p1, p2, p3).then( + function (results0, results1, results2, results3) { + // code + }); + +.. warning:: + + in a normal mapping, each parameter to the callback would be an + array: each promise is conceptually resolved with an array of 0..n + values and these values are passed to :js:func:`when`'s + callback. But jQuery treats deferreds resolving a single value + specially, and "unwraps" that value. + + For instance, in the code block above if the index of each promise + is the number of values it resolves (0 to 3), ``results0`` is an + empty array, ``results2`` is an array of 2 elements (a pair) but + ``results1`` is the actual value resolved by ``p1``, not an array. + +Deferred chaining +````````````````` + +A second useful composition is starting an asynchronous operation as +the result of an other asynchronous operation, and wanting the result +of both: :js:func:`Deferred.then` returns the deferred on which it was +called, so handle e.g. OpenERP's search/read sequence with this would +require something along the lines of: + +.. code-block:: javascript + + var result = $.Deferred(); + Model.search(condition).then(function (ids) { + Model.read(ids, fields).then(function (records) { + result.resolve(records); + }); + }); + return result.promise(); + +While it doesn't look too bad for trivial code, this quickly gets +unwieldy. + +Instead, jQuery provides a tool to handle this kind of chains: +:js:func:`Deferred.pipe`. + +:js:func:`~Deferred.pipe` has the same signature as +:js:func:`~Deferred.then` and could be used in the same manner +provided its return value was not used. + +It differs from :js:func:`~Deferred.then` in two ways: it returns a +new promise object, not the one it was called with, and the return +values of the callbacks is actually important to it: whichever +callback is called, + +* If the callback is not set (not provided or left to null), the + resolution or rejection value(s) is simply forwarded to + :js:func:`~Deferred.pipe`'s promise (it's essentially a noop) + +* If the callback is set and does not return an observable object (a + deferred or a promise), the value it returns (``undefined`` if it + does not return anything) will replace the value it was given, e.g. + + .. code-block:: javascript + + promise.pipe(function () { + console.log('called'); + }); + + will resolve with the sole value ``undefined``. + +* If the callback is set and returns an observable object, that object + will be the actual resolution (and result) of the pipe. This means a + resolved promise from the failure callback will resolve the pipe, + and a failure promise from the success callback will reject the + pipe. + + This provides an easy way to chain operation successes, and the + previous piece of code can now be rewritten: + + .. code-block:: javascript + + return Model.search(condition).pipe(function (ids) { + return Model.read(ids, fields); + }); + + the result of the whole expression will encode failure if either + ``search`` or ``read`` fails (with the right rejection values), and + will be resolved with ``read``'s resolution values if the chain + executes correctly. + +:js:func:`~Deferred.pipe` is also useful to adapt third-party +promise-based APIs, in order to filter their resolution value counts +for instance (to take advantage of :js:func:`when` 's special treatment +of single-value promises). + +jQuery.Deferred API +~~~~~~~~~~~~~~~~~~~ + +.. js:function:: when(deferreds…) + + :param deferreds: deferred objects to multiplex + :returns: a multiplexed deferred + :rtype: :js:class:`Deferred` + +.. js:class:: Deferred + + .. js:function:: Deferred.then(doneCallback[, failCallback]) + + Attaches new callbacks to the resolution or rejection of the + deferred object. Callbacks are executed in the order they are + attached to the deferred. + + To provide only a failure callback, pass ``null`` as the + ``doneCallback``, to provide only a success callback the + second argument can just be ignored (and not passed at all). + + :param doneCallback: function called when the deferred is resolved + :type doneCallback: Function + :param failCallback: function called when the deferred is rejected + :type failCallback: Function + :returns: the deferred object on which it was called + :rtype: :js:class:`Deferred` + + .. js:function:: Deferred.done(doneCallback) + + Attaches a new success callback to the deferred, shortcut for + ``deferred.then(doneCallback)``. + + This is a jQuery extension to `CommonJS Promises/A`_ providing + little value over calling :js:func:`~Deferred.then` directly, + it should be avoided. + + :param doneCallback: function called when the deferred is resolved + :type doneCallback: Function + :returns: the deferred object on which it was called + :rtype: :js:class:`Deferred` + + .. js:function:: Deferred.fail(failCallback) + + Attaches a new failure callback to the deferred, shortcut for + ``deferred.then(null, failCallback)``. + + A second jQuery extension to `Promises/A `_. Although it provides more value than + :js:func:`~Deferred.done`, it still is not much and should be + avoided as well. + + :param failCallback: function called when the deferred is rejected + :type failCallback: Function + :returns: the deferred object on which it was called + :rtype: :js:class:`Deferred` + + .. js:function:: Deferred.promise() + + Returns a read-only view of the deferred object, with all + mutators (resolve and reject) methods removed. + + .. js:function:: Deferred.resolve(value…) + + Called to resolve a deferred, any value provided will be + passed onto the success handlers of the deferred object. + + Resolving a deferred which has already been resolved or + rejected has no effect. + + .. js:function:: Deferred.reject(value…) + + Called to reject (fail) a deferred, any value provided will be + passed onto the failure handler of the deferred object. + + Rejecting a deferred which has already been resolved or + rejected has no effect. + + .. js:function:: Deferred.pipe(doneFilter[, failFilter]) + + Filters the result of a deferred, able to transform a success + into failure and a failure into success, or to delay + resolution further. + +.. [#] or simply calling :js:class:`Deferred` as a function, the + result is the same + +.. [#] or not-promises, the `CommonJS Promises/B`_ role of + :js:func:`when` is to be able to treat values and promises + uniformly: :js:func:`when` will pass promises through directly, + but non-promise values and objects will be transformed into a + resolved promise (resolving themselves with the value itself). + + jQuery's :js:func:`when` keeps this behavior making deferreds + easy to build from "static" values, or allowing defensive code + where expected promises are wrapped in :js:func:`when` just in + case. + +.. _promises: http://en.wikipedia.org/wiki/Promise_(programming) +.. _jQuery's deferred: http://api.jquery.com/category/deferred-object/ +.. _CommonJS Promises/A: http://wiki.commonjs.org/wiki/Promises/A +.. _CommonJS Promises/B: http://wiki.commonjs.org/wiki/Promises/B +.. _mocks: http://en.wikipedia.org/wiki/Mock_object diff --git a/doc/changelog-6.2.rst b/doc/changelog-6.2.rst new file mode 100644 index 00000000000..48d79bf6398 --- /dev/null +++ b/doc/changelog-6.2.rst @@ -0,0 +1,108 @@ +API changes from OpenERP Web 6.1 to 6.2 +======================================= + +DataSet -> Model +---------------- + +The 6.1 ``DataSet`` API has been deprecated in favor of the smaller +and more orthogonal :doc:`Model ` API, which more closely +matches the API in OpenERP Web's Python side and in OpenObject addons +and removes most stateful behavior of DataSet. + +Migration guide +~~~~~~~~~~~~~~~ + +* Actual arbitrary RPC calls can just be remapped on a + :js:class:`~openerp.web.Model` instance: + + .. code-block:: javascript + + dataset.call(method, args) + + or + + .. code-block:: javascript + + dataset.call_and_eval(method, args) + + can be replaced by calls to :js:func:`openerp.web.Model.call`: + + .. code-block:: javascript + + model.call(method, args) + + If callbacks are passed directly to the older methods, they need to + be added to the new one via ``.then()``. + + .. note:: + + The ``context_index`` and ``domain_index`` features were not + ported, context and domain now need to be passed in "in full", + they won't be automatically filled with the user's current + context. + +* Shorcut methods (``name_get``, ``name_search``, ``unlink``, + ``write``, ...) should be ported to + :js:func:`openerp.web.Model.call`, using the server's original + signature. On the other hand, the non-shortcut equivalents can now + use keyword arguments (see :js:func:`~openerp.web.Model.call`'s + signature for details) + +* ``read_slice``, which allowed a single round-trip to perform a + search and a read, should be reimplemented via + :js:class:`~openerp.web.Query` objects (see: + :js:func:`~openerp.web.Model.query`) for clearer and simpler + code. ``read_index`` should be replaced by a + :js:class:`~openerp.web.Query` as well, combining + :js:func:`~openerp.web.Query.offset` and + :js:func:`~openerp.web.Query.first`. + +Rationale +~~~~~~~~~ + +Renaming + + The name *DataSet* exists in the CS community consciousness, and + (as its name implies) it's a set of data (often fetched from a + database, maybe lazily). OpenERP Web's dataset behaves very + differently as it does not store (much) data (only a bunch of ids + and just enough state to break things). The name "Model" matches + the one used on the Python side for the task of building an RPC + proxy to OpenERP objects. + +API simplification + + ``DataSet`` has a number of methods which serve as little more + than shortcuts, or are there due to domain and context evaluation + issues in 6.1. + + The shortcuts really add little value, and OpenERP Web 6.2 embeds + a restricted Python evaluator (in javascript) meaning most of the + context and domain parsing & evaluation can be moved to the + javascript code and does not require cooperative RPC bridging. + +DataGroup -> also Model +----------------------- + +Alongside the deprecation of ``DataSet`` for +:js:class:`~openerp.web.Model`, OpenERP Web 6.2 also deprecates +``DataGroup`` and its subtypes in favor of a single method on +:js:class:`~openerp.web.Query`: +:js:func:`~openerp.web.Query.group_by`. + +Migration guide +~~~~~~~~~~~~~~~ + +Rationale +~~~~~~~~~ + +While the ``DataGroup`` API worked (mostly), it is quite odd and +alien-looking, a bit too Smalltalk-inspired (behaves like a +self-contained flow-control structure for reasons which may or may not +have been good). + +Because it is heavily related to ``DataSet`` (as it *yields* +``DataSet`` objects), deprecating ``DataSet`` automatically deprecates +``DataGroup`` (if we want to stay consistent), which is a good time to +make the API more imperative and look more like what most developers +are used to. diff --git a/doc/index.rst b/doc/index.rst index 815633aed54..888ff7705f2 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -8,6 +8,17 @@ Welcome to OpenERP Web's documentation! Contents: +.. toctree:: + :maxdepth: 1 + + changelog-6.2 + + async + rpc + +Older stuff +----------- + .. toctree:: :maxdepth: 2 diff --git a/doc/rpc.rst b/doc/rpc.rst new file mode 100644 index 00000000000..9f8f4424ce6 --- /dev/null +++ b/doc/rpc.rst @@ -0,0 +1,345 @@ +Outside the box: network interactions +===================================== + +Building static displays is all nice and good and allows for neat +effects (and sometimes you're given data to display from third parties +so you don't have to make any effort), but a point generally comes +where you'll want to talk to the world and make some network requests. + +OpenERP Web provides two primary APIs to handle this, a low-level +JSON-RPC based API communicating with the Python section of OpenERP +Web (and of your addon, if you have a Python part) and a high-level +API above that allowing your code to talk directly to the OpenERP +server, using familiar-looking calls. + +All networking APIs are :doc:`asynchronous `. As a result, all +of them will return :js:class:`Deferred` objects (whether they resolve +those with values or not). Understanding how those work before before +moving on is probably necessary. + +High-level API: calling into OpenERP models +------------------------------------------- + +Access to OpenERP object methods (made available through XML-RPC from +the server) is done via the :js:class:`openerp.web.Model` class. This +class maps ontwo the OpenERP server objects via two primary methods, +:js:func:`~openerp.web.Model.call` and +:js:func:`~openerp.web.Model.query`. + +:js:func:`~openerp.web.Model.call` is a direct mapping to the +corresponding method of the OpenERP server object. Its usage is +similar to that of the OpenERP Model API, with three differences: + +* The interface is :doc:`asynchronous `, so instead of + returning results directly RPC method calls will return + :js:class:`Deferred` instances, which will themselves resolve to the + result of the matching RPC call. + +* Because ECMAScript 3/Javascript 1.5 doesnt feature any equivalent to + ``__getattr__`` or ``method_missing``, there needs to be an explicit + method to dispatch RPC methods. + +* No notion of pooler, the model proxy is instantiated where needed, + not fetched from an other (somewhat global) object + +.. code-block:: javascript + + var Users = new Model('res.users'); + + Users.call('change_password', ['oldpassword', 'newpassword'], + {context: some_context}).then(function (result) { + // do something with change_password result + }); + +:js:func:`~openerp.web.Model.query` is a shortcut for a builder-style +iterface to searches (``search`` + ``read`` in OpenERP RPC terms). It +returns a :js:class:`~openerp.web.Query` object which is immutable but +allows building new :js:class:`~openerp.web.Query` instances from the +first one, adding new properties or modifiying the parent object's: + +.. code-block:: javascript + + Users.query(['name', 'login', 'user_email', 'signature']) + .filter([['active', '=', true], ['company_id', '=', main_company]]) + .limit(15) + .all().then(function (users) { + // do work with users records + }); + +The query is only actually performed when calling one of the query +serialization methods, :js:func:`~openerp.web.Query.all` and +:js:func:`~openerp.web.Query.first`. These methods will perform a new +RPC query every time they are called. + +For that reason, it's actually possible to keep "intermediate" queries +around and use them differently/add new specifications on them. + +.. js:class:: openerp.web.Model(name) + + .. js:attribute:: openerp.web.Model.name + + name of the OpenERP model this object is bound to + + .. js:function:: openerp.web.Model.call(method[, args][, kwargs]) + + Calls the ``method`` method of the current model, with the + provided positional and keyword arguments. + + :param String method: method to call over rpc on the + :js:attr:`~openerp.web.Model.name` + :param Array<> args: positional arguments to pass to the + method, optional + :param Object<> kwargs: keyword arguments to pass to the + method, optional + :rtype: Deferred<> + + .. js:function:: openerp.web.Model.query(fields) + + :param Array fields: list of fields to fetch during + the search + :returns: a :js:class:`~openerp.web.Query` object + representing the search to perform + +.. js:class:: openerp.web.Query(fields) + + The first set of methods is the "fetching" methods. They perform + RPC queries using the internal data of the object they're called + on. + + .. js:function:: openerp.web.Query.all() + + Fetches the result of the current + :js:class:`~openerp.web.Query` object's search. + + :rtype: Deferred> + + .. js:function:: openerp.web.Query.first() + + Fetches the **first** result of the current + :js:class:`~openerp.web.Query`, or ``null`` if the current + :js:class:`~openerp.web.Query` does have any result. + + :rtype: Deferred + + .. js:function:: openerp.web.Query.count() + + Fetches the number of records the current + :js:class:`~openerp.web.Query` would retrieve. + + :rtype: Deferred + + .. js:function:: openerp.web.Query.group_by(grouping...) + + Fetches the groups for the query, using the first specified + grouping parameter + + :param Array grouping: Lists the levels of grouping + asked of the server. Grouping + can actually be an array or + varargs. + :rtype: Deferred> | null + + The second set of methods is the "mutator" methods, they create a + **new** :js:class:`~openerp.web.Query` object with the relevant + (internal) attribute either augmented or replaced. + + .. js:function:: openerp.web.Query.context(ctx) + + Adds the provided ``ctx`` to the query, on top of any existing + context + + .. js:function:: openerp.web.Query.filter(domain) + + Adds the provided domain to the query, this domain is + ``AND``-ed to the existing query domain. + + .. js:function:: opeenrp.web.Query.offset(offset) + + Sets the provided offset on the query. The new offset + *replaces* the old one. + + .. js:function:: openerp.web.Query.limit(limit) + + Sets the provided limit on the query. The new limit *replaces* + the old one. + + .. js:function:: openerp.web.Query.order_by(fields…) + + Overrides the model's natural order with the provided field + specifications. Behaves much like Django's `QuerySet.order_by + `_: + + * Takes 1..n field names, in order of most to least importance + (the first field is the first sorting key). Fields are + provided as strings. + + * A field specifies an ascending order, unless it is prefixed + with the minus sign "``-``" in which case the field is used + in the descending order + + Divergences from Django's sorting include a lack of random sort + (``?`` field) and the inability to "drill down" into relations + for sorting. + +Aggregation (grouping) +~~~~~~~~~~~~~~~~~~~~~~ + +OpenERP has powerful grouping capacities, but they are kind-of strange +in that they're recursive, and level n+1 relies on data provided +directly by the grouping at level n. As a result, while ``read_group`` +works it's not a very intuitive API. + +OpenERP Web 6.2 eschews direct calls to ``read_group`` in favor of +calling a method of :js:class:`~openerp.web.Query`, `much in the way +it is one in SQLAlchemy +`_ [#]_: + +.. code-block:: javascript + + some_query.group_by(['field1', 'field2']).then(function (groups) { + // do things with the fetched groups + }); + +This method is asynchronous when provided with 1..n fields (to group +on) as argument, but it can also be called without any field (empty +fields collection or nothing at all). In this case, instead of +returning a Deferred object it will return ``null``. + +When grouping criterion come from a third-party and may or may not +list fields (e.g. could be an empty list), this provides two ways to +test the presence of actual subgroups (versus the need to perform a +regular query for records): + +* A check on ``group_by``'s result and two completely separate code + paths + + .. code-block:: javascript + + var groups; + if (groups = some_query.group_by(gby)) { + groups.then(function (gs) { + // groups + }); + } + // no groups + +* Or a more coherent code path using :js:func:`when`'s ability to + coerce values into deferreds: + + .. code-block:: javascript + + $.when(some_query.group_by(gby)).then(function (groups) { + if (!groups) { + // No grouping + } else { + // grouping, even if there are no groups (groups + // itself could be an empty array) + } + }); + +The result of a (successful) :js:func:`~openerp.web.Query.group_by` is +an array of :js:class:`~openerp.web.data.Group`. + +Synchronizing views (provisional) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. note:: this API may not be final, and may not even remain + +While the high-level RPC API is mostly stateless, some objects in +OpenERP Web need to share state information. One of those is OpenERP +views, especially between "collection-based" views (lists, graphs) and +"record-based" views (forms, diagrams), which gets its very own API +for traversing collections of records, the aptly-named +:js:class:`~openerp.web.Traverser`. + +A :js:class:`~openerp.web.Traverser` is linked to a +:js:class:`~openerp.web.Model` and is used to iterate over it +asynchronously (and using indexes). + +.. js:class:: openerp.web.Traverser(model) + + .. js:function:: openerp.web.Traverser.model() + + :returns: the :js:class:`~openerp.web.Model` this traverser + instance is bound to + + .. js:function:: openerp.web.Traverser.index([idx]) + + If provided with an index parameter, sets that as the new + index for the traverser. + + :param Number idx: the new index for the traverser + :returns: the current index for the traverser + + .. js:function:: openerp.web.Traverser.current([fields]) + + Fetches the traverser's "current" record (that is, the record + at the current index of the traverser) + + :param Array fields: fields to return in the record + :rtype: Deferred<> + + .. js:function:: openerp.web.Traverser.next([fields]) + + Increases the traverser's internal index by one, the fetches + the corresponding record. Roughly equivalent to: + + .. code-block:: javascript + + var idx = traverser.index(); + traverser.index(idx+1); + traverser.current(); + + :param Array fields: fields to return in the record + :rtype: Deferred<> + + .. js:function:: openerp.web.Traverser.previous([fields]) + + Similar to :js:func:`~openerp.web.Traverser.next` but iterates + the traverser backwards rather than forward. + + :param Array fields: fields to return in the record + :rtype: Deferred<> + + .. js:function:: openerp.web.Traverser.size() + + Shortcut to checking the size of the backing model, calling + ``traverser.size()`` is equivalent to calling + ``traverser.model().query([]).count()`` + + :rtype: Deferred + +Low-level API: RPC calls to Python side +--------------------------------------- + +While the previous section is great for calling core OpenERP code +(models code), it does not work if you want to call the Python side of +openerp-web. + +For this. a lower-level API is available on +:js:class:`openerp.web.Connection` objects (usually available through +``openerp.connection``): the ``rpc`` method. + +This method simply takes an absolute path (which is the combination of +the Python controller's ``_cp_path`` attribute and the name of the +method yo want to call) and a mapping of attributes to values (applied +as keyword arguments on the Python method [#]_). This function fetches +the return value of the Python methods, converted to JSON. + +For instance, to call the ``eval_domain_and_context`` of the +:class:`~web.controllers.main.Session` controller: + +.. code-block:: javascript + + openerp.connection.rpc('/web/session/eval_domain_and_context', { + domains: ds, + contexts: cs + }).then(function (result) { + // handle result + }); + +.. [#] with a small twist: SQLAlchemy's ``orm.query.Query.group_by`` + is not terminal, it returns a query which can still be altered. + +.. [#] except for ``context``, which is extracted and stored in the + request object itself. diff --git a/openerp-web b/openerp-web index ec72085db2c..3340b95646e 100755 --- a/openerp-web +++ b/openerp-web @@ -50,6 +50,19 @@ logging_opts.add_option("--log-config", dest="log_config", default=os.path.join( help="Logging configuration file", metavar="FILE") optparser.add_option_group(logging_opts) +testing_opts = optparse.OptionGroup(optparser, "Testing") +testing_opts.add_option('--test-mode', dest='test_mode', + action='store_true', default=False, + help="Starts test mode, which provides a few" + " (utterly unsafe) APIs for testing purposes and" + " sets up a special connector which always raises" + " errors on tentative server access. These errors" + " serialize RPC query information (service," + " method, arguments list) in the fault_code" + " attribute of the error object returned to the" + " client. This lets javascript code assert the" \ + " XMLRPC consequences of its queries.") +optparser.add_option_group(testing_opts) if __name__ == "__main__": (options, args) = optparser.parse_args(sys.argv[1:]) @@ -78,6 +91,12 @@ if __name__ == "__main__": options.backend = 'xmlrpc' os.environ["TZ"] = "UTC" + if options.test_mode: + import web.test_support + import web.test_support.controllers + options.connector = web.test_support.TestConnector() + logging.getLogger('werkzeug').setLevel(logging.WARNING) + if sys.version_info >= (2, 7) and os.path.exists(options.log_config): with open(options.log_config) as file: dct = json.load(file)