(function() { var instance = openerp; openerp.web.data = {}; /** * Serializes the sort criterion array of a dataset into a form which can be * consumed by OpenERP's RPC APIs. * * @param {Array} criterion array of fields, from first to last criteria, prefixed with '-' for reverse sorting * @returns {String} SQL-like sorting string (``ORDER BY``) clause */ instance.web.serialize_sort = function (criterion) { return _.map(criterion, function (criteria) { if (criteria[0] === '-') { return criteria.slice(1) + ' DESC'; } return criteria + ' ASC'; }).join(', '); }; 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.session.rpc('/web/dataset/search_read', { model: this._model.name, fields: this._fields || false, domain: instance.web.pyeval.eval('domains', [this._model.domain(this._filter)]), context: instance.web.pyeval.eval('contexts', [this._model.context(this._context)]), offset: this._offset, limit: this._limit, sort: instance.web.serialize_sort(this._order_by) }).then(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().then(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) { var ctx = instance.web.pyeval.eval( 'context', this._model.context(this._context)); // undefined passed in explicitly (!) if (_.isUndefined(grouping)) { grouping = []; } if (!(grouping instanceof Array)) { grouping = _.toArray(arguments); } if (_.isEmpty(grouping) && !ctx['group_by_no_leaf']) { return null; } var raw_fields = _.map(grouping.concat(this._fields || []), function (field) { return field.split(':')[0]; }); var self = this; return this._model.call('read_group', { groupby: grouping, fields: _.uniq(raw_fields), domain: this._model.domain(this._filter), context: ctx, offset: this._offset, limit: this._limit, orderby: instance.web.serialize_sort(this._order_by) || false }).then(function (results) { return _(results).map(function (result) { // FIX: querygroup initialization result.__context = result.__context || {}; result.__context.group_by = result.__context.group_by || []; _.defaults(result.__context, ctx); return new instance.web.QueryGroup( 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.QueryGroup = instance.web.Class.extend({ 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 raw_field = grouping_field && grouping_field.split(':')[0]; var aggregates = {}; _(fixed_group).each(function (value, key) { if (key.indexOf('__') === 0 || key === raw_field || key === raw_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[raw_field + '_count'] || fixed_group.__count || 0; var leaf_group = fixed_group.__context.group_by.length === 0; this.attributes = { folded: !!(fixed_group.__fold), grouped_on: grouping_field, // if terminal group (or no group) and group_by_no_leaf => use group.__count length: group_size, value: fixed_group[raw_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.Model.include({ /** new openerp.web.Model([session,] model_name[, context[, domain]]) @constructs instance.web.Model @extends instance.web.Class @param {openerp.web.Session} [session] The session object used to communicate with the server. @param {String} model_name name of the OpenERP model this object is bound to @param {Object} [context] @param {Array} [domain] */ init: function() { var session, model_name, context, domain; var args = _.toArray(arguments); args.reverse(); session = args.pop(); if (session && ! (session instanceof openerp.web.Session)) { model_name = session; session = null; } else { model_name = args.pop(); } context = args.length > 0 ? args.pop() : null; domain = args.length > 0 ? args.pop() : null; this._super(session, model_name, context, domain); this._context = context || {}; this._domain = domain || []; }, session: function() { if (! this._session) return instance.session; return this._super(); }, /** * @deprecated does not allow to specify kwargs, directly use call() instead */ get_func: function (method_name) { var self = this; return function () { return self.call(method_name, _.toArray(arguments)); }; }, /** * Call a method (over RPC) on the bound OpenERP model. * * @param {String} method name of the method to call * @param {Array} [args] positional arguments * @param {Object} [kwargs] keyword arguments * @param {Object} [options] additional options for the rpc() method * @returns {jQuery.Deferred<>} call result */ call: function (method, args, kwargs, options) { args = args || []; kwargs = kwargs || {}; if (!_.isArray(args)) { // call(method, kwargs) kwargs = args; args = []; } instance.web.pyeval.ensure_evaluated(args, kwargs); return this._super(method, args, kwargs, options); }, /** * Fetches a Query instance bound to this model, for searching * * @param {Array} [fields] fields to ultimately fetch during the search * @returns {instance.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 this.session().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 {instance.web.CompoundDomain} The model's internal domain, or the AND-ed union of the model's internal domain and the provided domain */ domain: function (domain) { if (!domain) { return this._domain; } return new 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 {instance.web.CompoundContext} The union of the user's context and the model's internal context, as well as the provided context if any. In that order. */ context: function (context) { return new instance.web.CompoundContext( instance.session.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) { instance.web.pyeval.ensure_evaluated(args, {}); return this.session().rpc('/web/dataset/call_button', { model: this.name, method: method, // Should not be necessary anymore. Integrate remote in this? domain_id: null, context_id: args.length - 1, args: args || [] }); }, }); instance.web.DataSet = instance.web.Class.extend(instance.web.PropertiesMixin, { /** * Collection of OpenERP records, used to share records and the current selection between views. * * @constructs instance.web.DataSet * * @param {String} model the OpenERP model this dataset will manage */ init: function(parent, model, context) { instance.web.PropertiesMixin.init.call(this); this.model = model; this.context = context || {}; this.index = null; this._sort = []; this._model = new instance.web.Model(model, context); }, previous: function () { this.index -= 1; if (!this.ids.length) { this.index = null; } else if (this.index < 0) { this.index = this.ids.length - 1; } return this; }, next: function () { this.index += 1; if (!this.ids.length) { this.index = null; } else if (this.index >= this.ids.length) { this.index = 0; } return this; }, select_id: function(id) { var idx = this.get_id_index(id); if (idx === null) { return false; } else { this.index = idx; return true; } }, get_id_index: function(id) { for (var i=0, ii=this.ids.length; i= ids.length - 1) { this.index = ids.length - 1; } }, unlink: function(ids) { this.set_ids(_.without.apply(null, [this.ids].concat(ids))); this.trigger('unlink', ids); return $.Deferred().resolve({result: true}); }, }); instance.web.DataSetSearch = instance.web.DataSet.extend({ /** * @constructs instance.web.DataSetSearch * @extends instance.web.DataSet * * @param {Object} parent * @param {String} model * @param {Object} context * @param {Array} domain */ init: function(parent, model, context, domain) { this._super(parent, model, context); this.domain = domain || []; this._length = null; this.ids = []; this._model = new instance.web.Model(model, context, domain); }, /** * Read a slice of the records represented by this DataSet, based on its * domain and context. * * @params {Object} options * @param {Array} [options.fields] fields to read and return, by default all fields are returned * @param {Object} [options.context] context data to add to the request payload, on top of the DataSet's own context * @param {Array} [options.domain] domain data to add to the request payload, ANDed with the dataset's domain * @param {Number} [options.offset=0] The index from which selected records should be returned * @param {Number} [options.limit=null] The maximum number of records to return * @returns {$.Deferred} */ read_slice: function (fields, options) { options = options || {}; var self = this; var q = this._model.query(fields || false) .filter(options.domain) .context(options.context) .offset(options.offset || 0) .limit(options.limit || false); q = q.order_by.apply(q, this._sort); return q.all().done(function (records) { // FIXME: not sure about that one, *could* have discarded count q.count().done(function (count) { self._length = count; }); self.ids = _(records).pluck('id'); }); }, get_domain: function (other_domain) { return this._model.domain(other_domain); }, alter_ids: function (ids) { this._super(ids); if (this.index !== null && this.index >= this.ids.length) { this.index = this.ids.length > 0 ? this.ids.length - 1 : 0; } }, remove_ids: function (ids) { var before = this.ids.length; this._super(ids); if (this._length) { this._length -= (before - this.ids.length); } }, unlink: function(ids, callback, error_callback) { var self = this; return this._super(ids).done(function(result) { self.remove_ids( ids); self.trigger("dataset_changed", ids, callback, error_callback); }); }, size: function () { if (this._length !== null) { return this._length; } return this._super(); } }); instance.web.BufferedDataSet = instance.web.DataSetStatic.extend({ virtual_id_prefix: "one2many_v_id_", debug_mode: true, init: function() { this._super.apply(this, arguments); this.reset_ids([]); this.last_default_get = {}; this.running_reads = []; }, default_get: function(fields, options) { var self = this; return this._super(fields, options).done(function(res) { self.last_default_get = res; }); }, create: function(data, options) { var cached = { id:_.uniqueId(this.virtual_id_prefix), values: _.extend({}, data, (options || {}).readonly_fields || {}), defaults: this.last_default_get }; this.to_create.push(_.extend(_.clone(cached), {values: _.clone(data)})); this.cache.push(cached); return $.Deferred().resolve(cached.id).promise(); }, write: function (id, data, options) { var self = this; var record = _.detect(this.to_create, function(x) {return x.id === id;}); record = record || _.detect(this.to_write, function(x) {return x.id === id;}); var dirty = false; if (record) { for (var k in data) { if (record.values[k] === undefined || record.values[k] !== data[k]) { dirty = true; break; } } $.extend(record.values, data); } else { dirty = true; record = {id: id, values: data}; self.to_write.push(record); } var cached = _.detect(this.cache, function(x) {return x.id === id;}); if (!cached) { cached = {id: id, values: {}}; this.cache.push(cached); } $.extend(cached.values, _.extend({}, record.values, (options || {}).readonly_fields || {})); if (dirty) this.trigger("dataset_changed", id, data, options); return $.Deferred().resolve(true).promise(); }, unlink: function(ids, callback, error_callback) { var self = this; _.each(ids, function(id) { if (! _.detect(self.to_create, function(x) { return x.id === id; })) { self.to_delete.push({id: id}); } }); this.to_create = _.reject(this.to_create, function(x) { return _.include(ids, x.id);}); this.to_write = _.reject(this.to_write, function(x) { return _.include(ids, x.id);}); this.cache = _.reject(this.cache, function(x) { return _.include(ids, x.id);}); this.set_ids(_.without.apply(_, [this.ids].concat(ids))); this.trigger("dataset_changed", ids, callback, error_callback); return $.async_when({result: true}).done(callback); }, reset_ids: function(ids) { this.set_ids(ids); this.to_delete = []; this.to_create = []; this.to_write = []; this.cache = []; this.delete_all = false; _.each(_.clone(this.running_reads), function(el) { el.reject(); }); }, read_ids: function (ids, fields, options) { var self = this; var to_get = []; _.each(ids, function(id) { var cached = _.detect(self.cache, function(x) {return x.id === id;}); var created = _.detect(self.to_create, function(x) {return x.id === id;}); if (created) { _.each(fields, function(x) {if (cached.values[x] === undefined) cached.values[x] = created.defaults[x] || false;}); } else { if (!cached || !_.all(fields, function(x) {return cached.values[x] !== undefined;})) to_get.push(id); } }); var return_records = function() { var records = _.map(ids, function(id) { var c = _.find(self.cache, function(c) {return c.id === id;}); return _.isUndefined(c) ? c : _.extend({}, c.values, {"id": id}); }); if (self.debug_mode) { if (_.include(records, undefined)) { throw "Record not correctly loaded"; } } var sort_fields = self._sort, compare = function (v1, v2) { return (v1 < v2) ? -1 : (v1 > v2) ? 1 : 0; }; // Array.sort is not necessarily stable. We must be careful with this because // sorting an array where all items are considered equal is a worst-case that // will randomize the array with an unstable sort! Therefore we must avoid // sorting if there are no sort_fields (i.e. all items are considered equal) // See also: http://ecma262-5.com/ELS5_Section_15.htm#Section_15.4.4.11 // http://code.google.com/p/v8/issues/detail?id=90 if (sort_fields.length) { records.sort(function (a, b) { return _.reduce(sort_fields, function (acc, field) { if (acc) { return acc; } var sign = 1; if (field[0] === '-') { sign = -1; field = field.slice(1); } //m2o should be searched based on value[1] not based whole value(i.e. [id, value]) if(_.isArray(a[field]) && a[field].length == 2 && _.isString(a[field][1])){ return sign * compare(a[field][1], b[field][1]); } return sign * compare(a[field], b[field]); }, 0); }); } return $.when(records); }; if(to_get.length > 0) { var def = $.Deferred(); self.running_reads.push(def); def.always(function() { self.running_reads = _.without(self.running_reads, def); }); this._super(to_get, fields, options).then(function() { def.resolve.apply(def, arguments); }, function() { def.reject.apply(def, arguments); }); return def.then(function(records) { _.each(records, function(record, index) { var id = to_get[index]; var cached = _.detect(self.cache, function(x) {return x.id === id;}); if (!cached) { self.cache.push({id: id, values: record}); } else { // I assume cache value is prioritary cached.values = _.defaults(_.clone(cached.values), record); } }); return return_records(); }); } else { return return_records(); } }, /** * Invalidates caching of a record in the dataset to ensure the next read * of that record will hit the server. * * Of use when an action is going to remote-alter a record which will then * need to be reloaded, e.g. action button. * * @param {Object} id record to remove from the BDS's cache */ evict_record: function (id) { // Don't evict records which haven't yet been saved: there is no more // recent data on the server (and there potentially isn't any data), // and this breaks the assumptions of other methods (that the data // for new and altered records is both in the cache and in the to_write // or to_create collection) if (_(this.to_create.concat(this.to_write)).find(function (record) { return record.id === id; })) { return; } for(var i=0, len=this.cache.length; i self.rsn) { self.rsn = seq; res.resolve.apply(res, arguments); } else if (self.failMisordered) { res.reject(); } }).fail(function () { res.reject.apply(res, arguments); }); return res.promise(); } }); instance.web.SimpleIndexedDB = instance.web.Class.extend({ /** * A simple wrapper around IndexedDB that provides an asynchronous * localStorage-like Key-Value api that persists between browser * restarts. * * It will not work if the browser doesn't support a recent version * of IndexedDB, and it may fail if the user refuses db access. * * All instances of SimpleIndexedDB will by default refer to the same * IndexedDB database, if you want to pick another one, use the 'name' * option on instanciation. */ init: function(opts){ var self = this; var opts = opts || {}; this.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB; this.db = undefined; this._ready = new $.Deferred(); if(this.indexedDB && this.indexedDB.open){ var request = this.indexedDB.open( opts.name || "SimpleIndexedDB" ,1); request.onerror = function(event){ self.db = null; self._ready.reject(event.target.error); }; request.onsuccess = function(event){ self.db = request.result; self._ready.resolve(); }; request.onupgradeneeded = function(event){ self.db = event.target.result; var objectStore = self.db.createObjectStore("keyvals", { keyPath:"key" }); self._ready.resolve(); }; }else{ this.db = null; this._ready.reject({type:'UnknownError', message:'IndexedDB is not supported by your Browser'}); } }, /** * returns true if the browser supports the necessary IndexedDB API * (but doesn't tell if the db can be created, check ready() for that) */ isSupportedByBrowser: function(){ return this.indexedDB && this.indexedDB.open; }, /** * returns a deferred that resolves when the db has been successfully * initialized. done/failed callbacks are optional. */ ready: function(done,failed){ this._ready.then(done,failed); return this._ready.promise(); }, /** * fetches the value associated to 'key' in the db. if the key doesn't * exists, it returns undefined. The returned value is provided as a * deferred, or as the first parameter of the optional 'done' callback. */ getItem: function(key,done,failed){ var self = this; var def = new $.Deferred(); def.then(done,failed); this._ready.then(function(){ var request = self.db.transaction(["keyvals"],"readwrite") .objectStore("keyvals") .get(key); request.onsuccess = function(){ def.resolve(request.result ? request.result.value : undefined); }; request.onerror = function(event){ def.reject(event.target.error); }; },function(){ def.reject({type:'UnknownError', message:'Could not initialize the IndexedDB database'}); }); return def.promise(); }, /** * Associates a value to 'key' in the db, overwriting previous value if * necessary. Contrary to localStorage, the value is not limited to strings and * can be any javascript object, even with cyclic references ! * * Be sure to check for failure as the user may refuse to have data localy stored. */ setItem: function(key,value,done,failed){ var self = this; var def = new $.Deferred(); def.then(done,failed); this._ready.then(function(){ var request = self.db.transaction(["keyvals"],"readwrite") .objectStore("keyvals") .put( {key:key, value:value} ); request.onsuccess = function(){ def.resolve(); }; request.onerror = function(event){ def.reject(event.target.error); }; },function(){ def.reject({type:'UnknownError', message:'Could not initialize the IndexedDB database'}); }); return def.promise(); }, /** * Removes the value associated with 'key' from the db. */ removeItem: function(key,done,failed){ var self = this; var def = new $.Deferred(); def.then(done,failed); this._ready.then(function(){ var request = self.db.transaction(["keyvals"],"readwrite") .objectStore("keyvals") .delete(key); request.onsuccess = function(){ def.resolve(); }; request.onerror = function(event){ def.reject(event.target.error); }; },function(){ def.reject({type:'UnknownError', message:'Could not initialize the IndexedDB database'}); }); return def.promise(); }, }); })(); // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax: