From 9f6af7c9747d8f85e3296d3044f13b735834dc70 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 21 Feb 2012 18:04:55 +0100 Subject: [PATCH 001/291] [ADD] asynchronous javascript guide, as it'll be needed by the networking/RPC guide bzr revid: xmo@openerp.com-20120221170455-rcrbyvz1ozj7mfib --- doc/source/async.rst | 353 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 doc/source/async.rst diff --git a/doc/source/async.rst b/doc/source/async.rst new file mode 100644 index 00000000000..23b3409bd8f --- /dev/null +++ b/doc/source/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 From f926427406a8fa6cd60af52476119bd1d49691dc Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 23 Feb 2012 13:28:29 +0100 Subject: [PATCH 002/291] [FIX] use an array for controller classes, so they are iterated in the correct order (otherwise it might not be possible to override an extended controller) bzr revid: xmo@openerp.com-20120223122829-mtz2w120qq8rbjrx --- addons/web/common/http.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/web/common/http.py b/addons/web/common/http.py index 71a280f27eb..26807f65b69 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 @@ -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 From b58012f32ab31b9a1b55297de915fe2257dea655 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 23 Feb 2012 13:31:51 +0100 Subject: [PATCH 003/291] [IMP] re-set session id on session reload bzr revid: xmo@openerp.com-20120223123151-w2s4g0y38z7xjoeu --- addons/web/static/src/js/core.js | 1 + 1 file changed, 1 insertion(+) diff --git a/addons/web/static/src/js/core.js b/addons/web/static/src/js/core.js index c4e37a8a3b1..10f3167b330 100644 --- a/addons/web/static/src/js/core.js +++ b/addons/web/static/src/js/core.js @@ -639,6 +639,7 @@ openerp.web.Connection = openerp.web.CallbackEnabled.extend( /** @lends openerp. // 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, From 5ac6e532bdfeef0f3bf8ad45ba8bc17183ff423c Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 23 Feb 2012 13:32:13 +0100 Subject: [PATCH 004/291] [FIX] if a connector was setup in the configuration object, don't re-set one instead bzr revid: xmo@openerp.com-20120223123213-aftgz7h0zxkbzsju --- addons/web/common/http.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/addons/web/common/http.py b/addons/web/common/http.py index 26807f65b69..9294808fda4 100644 --- a/addons/web/common/http.py +++ b/addons/web/common/http.py @@ -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 = {} From 0ff97e49295c9836bebc730049bafd23b9936978 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 23 Feb 2012 13:34:16 +0100 Subject: [PATCH 005/291] [ADD] testing scaffold which does not hit the OpenERP server bzr revid: xmo@openerp.com-20120223123416-2zvxy6nqbe9gdhhw --- addons/web/static/src/js/test_support.js | 64 ++++++++++++++++++++++ addons/web/static/test/fulltest.html | 49 +++++++++++++++++ addons/web/static/test/fulltest/dataset.js | 11 ++++ addons/web/test_support/__init__.py | 37 +++++++++++++ addons/web/test_support/controllers.py | 31 +++++++++++ openerp-web | 19 +++++++ 6 files changed, 211 insertions(+) create mode 100644 addons/web/static/src/js/test_support.js create mode 100644 addons/web/static/test/fulltest.html create mode 100644 addons/web/static/test/fulltest/dataset.js create mode 100644 addons/web/test_support/__init__.py create mode 100644 addons/web/test_support/controllers.py 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..5fcfb94c6a2 --- /dev/null +++ b/addons/web/static/src/js/test_support.js @@ -0,0 +1,64 @@ +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, fn) { + 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); + openerp.test_support.setup_connection(oe.connection) + .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'); + } + fn(e.data.fault_code); + }) + } +}; diff --git a/addons/web/static/test/fulltest.html b/addons/web/static/test/fulltest.html new file mode 100644 index 00000000000..f27adfa7df9 --- /dev/null +++ b/addons/web/static/test/fulltest.html @@ -0,0 +1,49 @@ + + + + + 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..4b948261a3b --- /dev/null +++ b/addons/web/static/test/fulltest/dataset.js @@ -0,0 +1,11 @@ +$(document).ready(function () { + var t = window.openerp.test_support; + + t.module('check', 'data'); + t.test('check1', function (openerp) { + var ds = new openerp.web.DataSet({session: openerp.connection}, 'res.users', {}); + t.expect(ds.create({name: 'foo'}), function (result) { + ok(false, 'ha ha ha') + }); + }); +}); 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..f8e3c7a0739 --- /dev/null +++ b/addons/web/test_support/controllers.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- + +from ..common.http import Controller, jsonrequest +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 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/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) From 27dfa4d12b2acc53176962f3cb0753e7349176ee Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Fri, 24 Feb 2012 11:57:53 +0100 Subject: [PATCH 006/291] [ADD] a bunch of tests of the dataset API (translation of javascript dataset calls to XMLRPC ones) bzr revid: xmo@openerp.com-20120224105753-hqf7o9xwwkc0xcok --- addons/web/static/test/fulltest/dataset.js | 144 ++++++++++++++++++++- 1 file changed, 139 insertions(+), 5 deletions(-) diff --git a/addons/web/static/test/fulltest/dataset.js b/addons/web/static/test/fulltest/dataset.js index 4b948261a3b..21b6b80cb80 100644 --- a/addons/web/static/test/fulltest/dataset.js +++ b/addons/web/static/test/fulltest/dataset.js @@ -1,11 +1,145 @@ $(document).ready(function () { var t = window.openerp.test_support; - t.module('check', 'data'); - t.test('check1', function (openerp) { - var ds = new openerp.web.DataSet({session: openerp.connection}, 'res.users', {}); - t.expect(ds.create({name: 'foo'}), function (result) { - ok(false, 'ha ha ha') + 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, 3); + deepEqual(result.args[0], [30]); + deepEqual(result.args[1], ['a', 'b', 'c']); + + ok(_.isEmpty(result.kwargs)); }); }); + 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, 2); + deepEqual(result.args[0], ['a', 'b', 'c']); + // FIXME: args[1] is context w/ user context, where to get? Hardcode? + strictEqual(result.args[1].foo, 'bar'); + + ok(_.isEmpty(result.kwargs)); + }); + }); + 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, 2); + deepEqual(r.args[0], {foo: 1, bar: 2}); + + ok(_.isEmpty(r.kwargs)); + }); + }); + 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, 3); + deepEqual(r.args[0], [42]); + deepEqual(r.args[1], {foo: 1}); + + ok(_.isEmpty(r.kwargs)); + }); + // 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, 2); + deepEqual(r.args[0], [42]); + + ok(_.isEmpty(r.kwargs)); + }); + }); + 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, 2); + deepEqual(r.args[0], [1, 2]); + + ok(_.isEmpty(r.kwargs)); + }); + }); + 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, 5); + strictEqual(r.args[0], 'bob'); + // domain + deepEqual(r.args[1], []); + strictEqual(r.args[2], 'ilike'); + strictEqual(r.args[4], 0); + + ok(_.isEmpty(r.kwargs)); + }); + }); + 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, 5); + strictEqual(r.args[0], ''); + // domain + deepEqual(r.args[1], [['foo', '=', 3]]); + strictEqual(r.args[2], 'someop'); + // limit + strictEqual(r.args[4], 0); + + ok(_.isEmpty(r.kwargs)); + }); + }); + 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); + }); + }); + // TODO: SearchDataSet#read_slice? + // TODO: non-literal domains and contexts basics + // TODO: call_and_eval + // TODO: name_search, non-literal domains + }); From f67ddedd2a8e802a309ace02511795366099b776 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Fri, 24 Feb 2012 13:26:20 +0100 Subject: [PATCH 007/291] [ADD] read_slice spec tests bzr revid: xmo@openerp.com-20120224122620-ji9h2sckpxx3kask --- addons/web/static/test/fulltest/dataset.js | 39 +++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/addons/web/static/test/fulltest/dataset.js b/addons/web/static/test/fulltest/dataset.js index 21b6b80cb80..d42f1b363d3 100644 --- a/addons/web/static/test/fulltest/dataset.js +++ b/addons/web/static/test/fulltest/dataset.js @@ -137,7 +137,44 @@ $(document).ready(function () { strictEqual(r.args[5], 42); }); }); - // TODO: SearchDataSet#read_slice? + + 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'); + + ok(_.isEmpty(r.kwargs)); + }); + }); // TODO: non-literal domains and contexts basics // TODO: call_and_eval // TODO: name_search, non-literal domains From 859991dda7922151644719416f83eb8475e7665a Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 27 Feb 2012 09:51:28 +0100 Subject: [PATCH 008/291] [ADD] nonliterals in dataset tests bzr revid: xmo@openerp.com-20120227085128-6ymxpa93pr59vjmb --- addons/web/static/src/js/test_support.js | 21 +++++++++-- addons/web/static/test/fulltest/dataset.js | 41 ++++++++++++++++++++-- addons/web/test_support/controllers.py | 14 +++++++- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/addons/web/static/src/js/test_support.js b/addons/web/static/src/js/test_support.js index 5fcfb94c6a2..d46366326b7 100644 --- a/addons/web/static/src/js/test_support.js +++ b/addons/web/static/src/js/test_support.js @@ -24,15 +24,26 @@ openerp.test_support = { }); return connection.session_reload(); }, - module: function (title, tested_core, fn) { + 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); - openerp.test_support.setup_connection(oe.connection) - .always(QUnit.start) + 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) { @@ -57,6 +68,10 @@ openerp.test_support = { .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/test/fulltest/dataset.js b/addons/web/static/test/fulltest/dataset.js index d42f1b363d3..14556e9f212 100644 --- a/addons/web/static/test/fulltest/dataset.js +++ b/addons/web/static/test/fulltest/dataset.js @@ -1,5 +1,8 @@ $(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) { @@ -14,6 +17,7 @@ $(document).ready(function () { strictEqual(result.args.length, 3); deepEqual(result.args[0], [30]); deepEqual(result.args[1], ['a', 'b', 'c']); + deepEqual(result.args[2], context_()); ok(_.isEmpty(result.kwargs)); }); @@ -27,8 +31,9 @@ $(document).ready(function () { strictEqual(result.args.length, 2); deepEqual(result.args[0], ['a', 'b', 'c']); - // FIXME: args[1] is context w/ user context, where to get? Hardcode? - strictEqual(result.args[1].foo, 'bar'); + console.log(result.args[1]); + console.log(context_({foo: 'bar'})); + deepEqual(result.args[1], context_({foo: 'bar'})); ok(_.isEmpty(result.kwargs)); }); @@ -40,6 +45,7 @@ $(document).ready(function () { strictEqual(r.args.length, 2); deepEqual(r.args[0], {foo: 1, bar: 2}); + deepEqual(r.args[1], context_()); ok(_.isEmpty(r.kwargs)); }); @@ -52,6 +58,7 @@ $(document).ready(function () { strictEqual(r.args.length, 3); deepEqual(r.args[0], [42]); deepEqual(r.args[1], {foo: 1}); + deepEqual(r.args[2], context_()); ok(_.isEmpty(r.kwargs)); }); @@ -68,6 +75,7 @@ $(document).ready(function () { strictEqual(r.args.length, 2); deepEqual(r.args[0], [42]); + deepEqual(r.args[1], context_()); ok(_.isEmpty(r.kwargs)); }); @@ -90,6 +98,7 @@ $(document).ready(function () { strictEqual(r.args.length, 2); deepEqual(r.args[0], [1, 2]); + deepEqual(r.args[1], context_()); ok(_.isEmpty(r.kwargs)); }); @@ -104,6 +113,7 @@ $(document).ready(function () { // domain deepEqual(r.args[1], []); strictEqual(r.args[2], 'ilike'); + deepEqual(r.args[3], context_()); strictEqual(r.args[4], 0); ok(_.isEmpty(r.kwargs)); @@ -119,6 +129,7 @@ $(document).ready(function () { // domain deepEqual(r.args[1], [['foo', '=', 3]]); strictEqual(r.args[2], 'someop'); + deepEqual(r.args[3], context_()); // limit strictEqual(r.args[4], 0); @@ -171,6 +182,32 @@ $(document).ready(function () { 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: ['mary had a little lamb', 'bob the builder'], + 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)); }); diff --git a/addons/web/test_support/controllers.py b/addons/web/test_support/controllers.py index f8e3c7a0739..a3719070730 100644 --- a/addons/web/test_support/controllers.py +++ b/addons/web/test_support/controllers.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from ..common.http import Controller, jsonrequest +from ..common import http, nonliterals from ..controllers.main import Session UID = 87539319 @@ -14,6 +14,18 @@ def bind(session): 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' From 910f217da1c60b2d28854c44b5306e21741f769e Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 27 Feb 2012 10:41:32 +0100 Subject: [PATCH 009/291] [IMP] update qunit to 1.3 bzr revid: xmo@openerp.com-20120227094132-dk41nad2mx854s4p --- addons/web/static/lib/qunit/qunit.css | 10 ++- addons/web/static/lib/qunit/qunit.js | 119 +++++++++++++++++--------- 2 files changed, 85 insertions(+), 44 deletions(-) mode change 100755 => 100644 addons/web/static/lib/qunit/qunit.css mode change 100755 => 100644 addons/web/static/lib/qunit/qunit.js 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() ); From db670cff7aa3f946fb0fd6c1b2cdeaf16c667f55 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 27 Feb 2012 11:26:05 +0100 Subject: [PATCH 010/291] Nonliteral domains, name_search w/ nonliteral domains bzr revid: xmo@openerp.com-20120227102605-wvn1osn1wxgbdiio --- addons/web/static/test/fulltest/dataset.js | 31 +++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/addons/web/static/test/fulltest/dataset.js b/addons/web/static/test/fulltest/dataset.js index 14556e9f212..4924e3b9c78 100644 --- a/addons/web/static/test/fulltest/dataset.js +++ b/addons/web/static/test/fulltest/dataset.js @@ -189,7 +189,10 @@ $(document).ready(function () { }); t.module('Nonliterals', 'data', { - domains: ['mary had a little lamb', 'bob the builder'], + domains: [ + "[('model_id', '=', parent.model)]", + "[('product_id','=',product_id)]" + ], contexts: ['{a: b > c}'] }); t.test('Dataset', function (openerp) { @@ -212,6 +215,32 @@ $(document).ready(function () { 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, 5); + strictEqual(r.args[0], 'foo'); + deepEqual(r.args[1], [['model_id', '=', 'qux']]); + strictEqual(r.args[2], 'ilike'); + deepEqual(r.args[3], context_()); + strictEqual(r.args[4], 0); + + ok(_.isEmpty(r.kwargs)); + }); + }); // TODO: non-literal domains and contexts basics // TODO: call_and_eval // TODO: name_search, non-literal domains From b8efd17bbb552f5516746a6edf27c509e89313e5 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 27 Feb 2012 11:28:20 +0100 Subject: [PATCH 011/291] [REM] todos bzr revid: xmo@openerp.com-20120227102820-t5hmrrsbcd5pcw61 --- addons/web/static/test/fulltest/dataset.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/addons/web/static/test/fulltest/dataset.js b/addons/web/static/test/fulltest/dataset.js index 4924e3b9c78..e9513b00b74 100644 --- a/addons/web/static/test/fulltest/dataset.js +++ b/addons/web/static/test/fulltest/dataset.js @@ -241,8 +241,4 @@ $(document).ready(function () { ok(_.isEmpty(r.kwargs)); }); }); - // TODO: non-literal domains and contexts basics - // TODO: call_and_eval - // TODO: name_search, non-literal domains - }); From e6f5d4c211e00ad8ce5f6cae24a65875c2bccf7c Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 27 Feb 2012 14:56:26 +0100 Subject: [PATCH 012/291] [ADD] Model API, reimplement DataSet/DataSetSearch on top of it (as much as possible) TODO: traversal state API, removing even more method (e.g. completely remove DataSet.call in the Python API) bzr revid: xmo@openerp.com-20120227135626-yxqh0gc6jwrdkshs --- addons/web/controllers/main.py | 73 +---- addons/web/static/src/js/core.js | 2 +- addons/web/static/src/js/data.js | 349 +++++++++++++-------- addons/web/static/test/fulltest/dataset.js | 103 +++--- doc/source/changelog-6.2.rst | 37 +++ doc/source/index.rst | 11 + doc/source/rpc.rst | 141 +++++++++ 7 files changed, 468 insertions(+), 248 deletions(-) create mode 100644 doc/source/changelog-6.2.rst create mode 100644 doc/source/rpc.rst diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index eaf3d671874..6a143d58231 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -819,11 +819,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) @@ -859,7 +854,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] } @@ -867,46 +861,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) @@ -916,23 +874,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) @@ -1008,19 +949,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/src/js/core.js b/addons/web/static/src/js/core.js index 10f3167b330..b07800203b2 100644 --- a/addons/web/static/src/js/core.js +++ b/addons/web/static/src/js/core.js @@ -452,7 +452,7 @@ openerp.web.Connection = openerp.web.CallbackEnabled.extend( /** @lends openerp. * setting the correct session id and session context in the parameter * objects * - * @param {String} url RPC endpoint + * @param {Object} url RPC endpoint * @param {Object} params call parameters * @param {Function} success_callback function to execute on RPC call success * @param {Function} error_callback function to execute on RPC call failure diff --git a/addons/web/static/src/js/data.js b/addons/web/static/src/js/data.js index 8e200951eaa..58e491cc52c 100644 --- a/addons/web/static/src/js/data.js +++ b/addons/web/static/src/js/data.js @@ -9,15 +9,167 @@ openerp.web.data = function(openerp) { * @returns {String} SQL-like sorting string (``ORDER BY``) clause */ openerp.web.serialize_sort = function (criterion) { - return _.map(criterion, - function (criteria) { - if (criteria[0] === '-') { - return criteria.slice(1) + ' DESC'; - } - return criteria + ' ASC'; - }).join(', '); + return _.map(criterion, + function (criteria) { + if (criteria[0] === '-') { + return criteria.slice(1) + ' DESC'; + } + return criteria + ' ASC'; + }).join(', '); }; +openerp.web.Query = openerp.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 openerp.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 openerp.web.CompoundDomain( + q._filter, to_set.filter); + break; + case 'context': + q._context = new openerp.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 openerp.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: openerp.web.serialize_sort(this._order_by) + }).pipe(function (results) { + self._count = results.length; + return results.records; + }, null); + }, + 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; + }); + }, + all: function () { + return this._execute(); + }, + context: function (context) { + if (!context) { return this; } + return this.clone({context: context}); + }, + count: function () { + if (this._count) { return $.when(this._count); } + return this.model.call( + 'search_count', [this._filter], { + context: this._model.context(this._context)}); + }, + filter: function (domain) { + if (!domain) { return this; } + return this.clone({filter: domain}); + }, + limit: function (limit) { + return this.clone({limit: limit}); + }, + offset: function (offset) { + return this.clone({offset: offset}); + }, + order_by: function () { + if (arguments.length === 0) { return this; } + return this.clone({order_by: _.toArray(arguments)}); + } +}); + +openerp.web.Model = openerp.web.CallbackEnabled.extend({ + init: function (model_name, context, domain) { + this._super(); + 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: function (method, args, kwargs) { + args = args || []; + kwargs = kwargs || {}; + return openerp.connection.rpc('/web/dataset/call_kw', { + model: this.name, + method: method, + args: args, + kwargs: kwargs + }); + }, + exec_workflow: function (id, signal) { + return openerp.connection.rpc('/web/dataset/exec_workflow', { + model: this.name, + id: id, + signal: signal + }); + }, + query: function (fields) { + return new openerp.web.Query(this, fields); + }, + domain: function (domain) { + return new openerp.web.CompoundDomain( + this._domain, domain || []); + }, + context: function (context) { + return new openerp.web.CompoundContext( + openerp.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 this.rpc('/web/dataset/call_button', { + model: this.model, + method: method, + domain_id: null, + context_id: args.length - 1, + args: args || [] + }); + }, +}); + openerp.web.DataGroup = openerp.web.OldWidget.extend( /** @lends openerp.web.DataGroup# */{ /** * Management interface between views and grouped collections of OpenERP @@ -249,6 +401,7 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data this.context = context || {}; this.index = null; this._sort = []; + this._model = new openerp.web.Model(model, context); }, previous: function () { this.index -= 1; @@ -296,13 +449,10 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data * @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) - }); + // 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 +465,14 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data * @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 +482,13 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data * @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 || {}; + // not very good + return this._model.query(fields) + .offset(this.index).first().pipe(function (record) { + if (!record) { return $.Deferred().reject().promise(); } + return record; + }); }, /** * Reads default values for the current model @@ -346,12 +498,9 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data * @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 +511,10 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data * @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 +527,10 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data */ 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 +540,9 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data * @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 +554,7 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data * @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 @@ -446,13 +588,8 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data * @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 +599,9 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data * @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 +613,30 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data * @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 openerp.web.CompoundContext(this.context, request_context); - } - return this.context; + return this._model.context(request_context); }, /** * Reads or changes sort criteria on the dataset. @@ -573,11 +713,9 @@ openerp.web.DataSetSearch = openerp.web.DataSet.extend(/** @lends openerp.web.D 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 openerp.web.Model(model, context, domain); }, /** * Read a slice of the records represented by this DataSet, based on its @@ -594,27 +732,20 @@ openerp.web.DataSetSearch = openerp.web.DataSet.extend(/** @lends openerp.web.D 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) { this._length = count; }); + self.ids = _(records).pluck('id'); }); }, get_domain: function (other_domain) { - if (other_domain) { - return new openerp.web.CompoundDomain(this.domain, other_domain); - } - return this.domain; + this._model.domain(other_domain); }, unlink: function(ids, callback, error_callback) { var self = this; @@ -841,34 +972,6 @@ openerp.web.ProxyDataSet = openerp.web.DataSetSearch.extend({ on_unlink: function(ids) {} }); -openerp.web.Model = openerp.web.CallbackEnabled.extend({ - init: function(model_name) { - this._super(); - this.model_name = model_name; - }, - rpc: function() { - var c = openerp.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 - }); - } -}); - openerp.web.CompoundContext = openerp.web.Class.extend({ init: function () { this.__ref = "compound_context"; diff --git a/addons/web/static/test/fulltest/dataset.js b/addons/web/static/test/fulltest/dataset.js index e9513b00b74..1b09bb7dc79 100644 --- a/addons/web/static/test/fulltest/dataset.js +++ b/addons/web/static/test/fulltest/dataset.js @@ -11,13 +11,15 @@ $(document).ready(function () { 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.method, 'search'); strictEqual(result.model, 'some.model'); - strictEqual(result.args.length, 3); - deepEqual(result.args[0], [30]); - deepEqual(result.args[1], ['a', 'b', 'c']); - deepEqual(result.args[2], context_()); + strictEqual(result.args.length, 5); + deepEqual(result.args[0], []); + strictEqual(result.args[1], 2); + strictEqual(result.args[2], 1); + strictEqual(result.args[3], false); + deepEqual(result.args[4], context_()); ok(_.isEmpty(result.kwargs)); }); @@ -29,13 +31,12 @@ $(document).ready(function () { strictEqual(result.method, 'default_get'); strictEqual(result.model, 'some.model'); - strictEqual(result.args.length, 2); + strictEqual(result.args.length, 1); deepEqual(result.args[0], ['a', 'b', 'c']); - console.log(result.args[1]); - console.log(context_({foo: 'bar'})); - deepEqual(result.args[1], context_({foo: 'bar'})); - ok(_.isEmpty(result.kwargs)); + deepEqual(result.kwargs, { + context: context_({foo: 'bar'}) + }); }); }); t.test('create', function (openerp) { @@ -43,11 +44,12 @@ $(document).ready(function () { t.expect(ds.create({foo: 1, bar: 2}), function (r) { strictEqual(r.method, 'create'); - strictEqual(r.args.length, 2); + strictEqual(r.args.length, 1); deepEqual(r.args[0], {foo: 1, bar: 2}); - deepEqual(r.args[1], context_()); - ok(_.isEmpty(r.kwargs)); + deepEqual(r.kwargs, { + context: context_() + }); }); }); t.test('write', function (openerp) { @@ -55,12 +57,12 @@ $(document).ready(function () { t.expect(ds.write(42, {foo: 1}), function (r) { strictEqual(r.method, 'write'); - strictEqual(r.args.length, 3); + strictEqual(r.args.length, 2); deepEqual(r.args[0], [42]); deepEqual(r.args[1], {foo: 1}); - deepEqual(r.args[2], context_()); - - ok(_.isEmpty(r.kwargs)); + 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) { @@ -73,11 +75,11 @@ $(document).ready(function () { t.expect(ds.unlink([42]), function (r) { strictEqual(r.method, 'unlink'); - strictEqual(r.args.length, 2); + strictEqual(r.args.length, 1); deepEqual(r.args[0], [42]); - deepEqual(r.args[1], context_()); - - ok(_.isEmpty(r.kwargs)); + deepEqual(r.kwargs, { + context: context_() + }); }); }); t.test('call', function (openerp) { @@ -96,11 +98,11 @@ $(document).ready(function () { t.expect(ds.name_get([1, 2], null), function (r) { strictEqual(r.method, 'name_get'); - strictEqual(r.args.length, 2); + strictEqual(r.args.length, 1); deepEqual(r.args[0], [1, 2]); - deepEqual(r.args[1], context_()); - - ok(_.isEmpty(r.kwargs)); + deepEqual(r.kwargs, { + context: context_() + }); }); }); t.test('name_search, name', function (openerp) { @@ -108,15 +110,14 @@ $(document).ready(function () { t.expect(ds.name_search('bob'), function (r) { strictEqual(r.method, 'name_search'); - strictEqual(r.args.length, 5); - strictEqual(r.args[0], 'bob'); - // domain - deepEqual(r.args[1], []); - strictEqual(r.args[2], 'ilike'); - deepEqual(r.args[3], context_()); - strictEqual(r.args[4], 0); - - ok(_.isEmpty(r.kwargs)); + 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) { @@ -124,16 +125,14 @@ $(document).ready(function () { t.expect(ds.name_search(0, [['foo', '=', 3]], 'someop'), function (r) { strictEqual(r.method, 'name_search'); - strictEqual(r.args.length, 5); - strictEqual(r.args[0], ''); - // domain - deepEqual(r.args[1], [['foo', '=', 3]]); - strictEqual(r.args[2], 'someop'); - deepEqual(r.args[3], context_()); - // limit - strictEqual(r.args[4], 0); - - ok(_.isEmpty(r.kwargs)); + strictEqual(r.args.length, 0); + deepEqual(r.kwargs, { + name: '', + args: [['foo', '=', 3]], + operator: 'someop', + context: context_(), + limit: 0 + }); }); }); t.test('exec_workflow', function (openerp) { @@ -231,14 +230,14 @@ $(document).ready(function () { t.expect(ds.name_search('foo', domain, 'ilike', 0), function (r) { strictEqual(r.method, 'name_search'); - strictEqual(r.args.length, 5); - strictEqual(r.args[0], 'foo'); - deepEqual(r.args[1], [['model_id', '=', 'qux']]); - strictEqual(r.args[2], 'ilike'); - deepEqual(r.args[3], context_()); - strictEqual(r.args[4], 0); - - ok(_.isEmpty(r.kwargs)); + strictEqual(r.args.length, 0); + deepEqual(r.kwargs, { + name: 'foo', + args: [['model_id', '=', 'qux']], + operator: 'ilike', + context: context_(), + limit: 0 + }); }); }); }); diff --git a/doc/source/changelog-6.2.rst b/doc/source/changelog-6.2.rst new file mode 100644 index 00000000000..2b492472f0f --- /dev/null +++ b/doc/source/changelog-6.2.rst @@ -0,0 +1,37 @@ +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 +~~~~~~~~~~~~~~~ + +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 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. diff --git a/doc/source/index.rst b/doc/source/index.rst index efa4d1ce812..99756633b43 100644 --- a/doc/source/index.rst +++ b/doc/source/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/source/rpc.rst b/doc/source/rpc.rst new file mode 100644 index 00000000000..8ed72e8520b --- /dev/null +++ b/doc/source/rpc.rst @@ -0,0 +1,141 @@ +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. + +: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. + +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. + +.. 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 + + 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. + +Low-level API: RPC calls to Python side +--------------------------------------- + From f26eff28a9a0af862eadcea90bf97bfae6c108f2 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 28 Feb 2012 14:03:21 +0100 Subject: [PATCH 013/291] [FIX] typos bzr revid: xmo@openerp.com-20120228130321-rzxy8axbqtnjr1ze --- addons/web/static/src/js/data.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/addons/web/static/src/js/data.js b/addons/web/static/src/js/data.js index 58e491cc52c..aa760e7df44 100644 --- a/addons/web/static/src/js/data.js +++ b/addons/web/static/src/js/data.js @@ -88,7 +88,7 @@ openerp.web.Query = openerp.web.Class.extend({ }, count: function () { if (this._count) { return $.when(this._count); } - return this.model.call( + return this._model.call( 'search_count', [this._filter], { context: this._model.context(this._context)}); }, @@ -449,6 +449,7 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data * @returns {$.Deferred} */ read_ids: function (ids, fields, options) { + options = options || {}; // TODO: reorder results to match ids list return this._model.call('read', [ids, fields || false], @@ -485,6 +486,7 @@ openerp.web.DataSet = openerp.web.OldWidget.extend( /** @lends openerp.web.Data options = options || {}; // not very good return this._model.query(fields) + .context(options.context) .offset(this.index).first().pipe(function (record) { if (!record) { return $.Deferred().reject().promise(); } return record; @@ -738,9 +740,10 @@ openerp.web.DataSetSearch = openerp.web.DataSet.extend(/** @lends openerp.web.D .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) { this._length = count; }); + q.count().then(function (count) { self._length = count; }); self.ids = _(records).pluck('id'); }); }, From 739d1c73637ce9afe8bb732422ed88066568dc38 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 28 Feb 2012 14:28:32 +0100 Subject: [PATCH 014/291] [ADD] basic docstrings to Query and Model bzr revid: xmo@openerp.com-20120228132832-pf16do6611fcwrj1 --- addons/web/static/src/js/data.js | 113 ++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 11 deletions(-) diff --git a/addons/web/static/src/js/data.js b/addons/web/static/src/js/data.js index aa760e7df44..969f3542908 100644 --- a/addons/web/static/src/js/data.js +++ b/addons/web/static/src/js/data.js @@ -71,6 +71,11 @@ openerp.web.Query = openerp.web.Class.extend({ 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) { @@ -79,43 +84,95 @@ openerp.web.Query = openerp.web.Class.extend({ return null; }); }, + /** + * Fetches all records matching the query + * + * @returns {jQuery.Deferred>} + */ all: function () { return this._execute(); }, - context: function (context) { - if (!context) { return this; } - return this.clone({context: context}); - }, + /** + * Fetches the number of records matching the query in the database + * + * @returns {jQuery.Deferred} + */ count: function () { if (this._count) { return $.when(this._count); } return this._model.call( 'search_count', [this._filter], { context: this._model.context(this._context)}); }, + /** + * 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 () { if (arguments.length === 0) { return this; } return this.clone({order_by: _.toArray(arguments)}); } }); -openerp.web.Model = openerp.web.CallbackEnabled.extend({ +openerp.web.Model = openerp.web.Class.extend(/** @lends openerp.web.Model# */{ + /** + * @constructs openerp.web.Model + * @extends openerp.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._super(); 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) { @@ -124,6 +181,14 @@ openerp.web.Model = openerp.web.CallbackEnabled.extend({ 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 || {}; @@ -134,6 +199,21 @@ openerp.web.Model = openerp.web.CallbackEnabled.extend({ 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 openerp.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 openerp.connection.rpc('/web/dataset/exec_workflow', { model: this.name, @@ -141,13 +221,24 @@ openerp.web.Model = openerp.web.CallbackEnabled.extend({ signal: signal }); }, - query: function (fields) { - return new openerp.web.Query(this, fields); - }, + /** + * Fetches the model's domain, combined with the provided domain if any + * + * @param {Array} [domain] to combine with the model's internal domain + * @returns 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 openerp.web.CompoundDomain( - this._domain, domain || []); + 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 openerp.web.CompoundContext( openerp.connection.user_context, this._context, context || {}); From 3bf4789ab2c285dc7cbc0151f8b1d0d6f7cebe01 Mon Sep 17 00:00:00 2001 From: "Mayur Maheshwari (OpenERP)" Date: Wed, 29 Feb 2012 17:36:38 +0530 Subject: [PATCH 015/291] [IMP]stock:Improve code of action_asign, added button print, set a position of button,removed a process later button bzr revid: mma@tinyerp.com-20120229120638-rcnskz1qivrun94p --- addons/stock/stock.py | 3 +++ addons/stock/stock_view.xml | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/addons/stock/stock.py b/addons/stock/stock.py index 885f7cd2077..9d01581a749 100644 --- a/addons/stock/stock.py +++ b/addons/stock/stock.py @@ -738,7 +738,10 @@ class stock_picking(osv.osv): """ Changes state of picking to available if all moves are confirmed. @return: True """ + wf_service = netsvc.LocalService("workflow") for pick in self.browse(cr, uid, ids): + if pick.state == 'draft': + wf_service.trg_validate(uid, 'stock.picking', pick.id,'button_confirm', cr) move_ids = [x.id for x in pick.move_lines if x.state == 'confirmed'] if not move_ids: raise osv.except_osv(_('Warning !'),_('Not enough stock, unable to reserve the products.')) diff --git a/addons/stock/stock_view.xml b/addons/stock/stock_view.xml index 4264a488436..8dd76e7c300 100644 --- a/addons/stock/stock_view.xml +++ b/addons/stock/stock_view.xml @@ -989,14 +989,14 @@ -