summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views/graph/graph_model.js
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src/js/views/graph/graph_model.js')
-rw-r--r--addons/web/static/src/js/views/graph/graph_model.js322
1 files changed, 322 insertions, 0 deletions
diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js
new file mode 100644
index 00000000..b1bcddb4
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_model.js
@@ -0,0 +1,322 @@
+odoo.define('web.GraphModel', function (require) {
+"use strict";
+
+var core = require('web.core');
+const { DEFAULT_INTERVAL, rankInterval } = require('web.searchUtils');
+
+var _t = core._t;
+
+/**
+ * The graph model is responsible for fetching and processing data from the
+ * server. It basically just do a(some) read_group(s) and format/normalize data.
+ */
+var AbstractModel = require('web.AbstractModel');
+
+return AbstractModel.extend({
+ /**
+ * @override
+ * @param {Widget} parent
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.chart = null;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ *
+ * We defend against outside modifications by extending the chart data. It
+ * may be overkill.
+ *
+ * @override
+ * @returns {Object}
+ */
+ __get: function () {
+ return Object.assign({ isSample: this.isSampleModel }, this.chart);
+ },
+ /**
+ * Initial loading.
+ *
+ * @todo All the work to fall back on the graph_groupbys keys in the context
+ * should be done by the graphView I think.
+ *
+ * @param {Object} params
+ * @param {Object} params.context
+ * @param {Object} params.fields
+ * @param {string[]} params.domain
+ * @param {string[]} params.groupBys a list of valid field names
+ * @param {string[]} params.groupedBy a list of valid field names
+ * @param {boolean} params.stacked
+ * @param {string} params.measure a valid field name
+ * @param {'pie'|'bar'|'line'} params.mode
+ * @param {string} params.modelName
+ * @param {Object} params.timeRanges
+ * @returns {Promise} The promise does not return a handle, we don't need
+ * to keep track of various entities.
+ */
+ __load: function (params) {
+ var groupBys = params.context.graph_groupbys || params.groupBys;
+ this.initialGroupBys = groupBys;
+ this.fields = params.fields;
+ this.modelName = params.modelName;
+ this.chart = Object.assign({
+ context: params.context,
+ dataPoints: [],
+ domain: params.domain,
+ groupBy: params.groupedBy.length ? params.groupedBy : groupBys,
+ measure: params.context.graph_measure || params.measure,
+ mode: params.context.graph_mode || params.mode,
+ origins: [],
+ stacked: params.stacked,
+ timeRanges: params.timeRanges,
+ orderBy: params.orderBy
+ });
+
+ this._computeDerivedParams();
+
+ return this._loadGraph();
+ },
+ /**
+ * Reload data. It is similar to the load function. Note that we ignore the
+ * handle parameter, we always expect our data to be in this.chart object.
+ *
+ * @todo This method takes 'groupBy' and load method takes 'groupedBy'. This
+ * is insane.
+ *
+ * @param {any} handle ignored!
+ * @param {Object} params
+ * @param {boolean} [params.stacked]
+ * @param {Object} [params.context]
+ * @param {string[]} [params.domain]
+ * @param {string[]} [params.groupBy]
+ * @param {string} [params.measure] a valid field name
+ * @param {string} [params.mode] one of 'bar', 'pie', 'line'
+ * @param {Object} [params.timeRanges]
+ * @returns {Promise}
+ */
+ __reload: function (handle, params) {
+ if ('context' in params) {
+ this.chart.context = params.context;
+ this.chart.groupBy = params.context.graph_groupbys || this.chart.groupBy;
+ this.chart.measure = params.context.graph_measure || this.chart.measure;
+ this.chart.mode = params.context.graph_mode || this.chart.mode;
+ }
+ if ('domain' in params) {
+ this.chart.domain = params.domain;
+ }
+ if ('groupBy' in params) {
+ this.chart.groupBy = params.groupBy.length ? params.groupBy : this.initialGroupBys;
+ }
+ if ('measure' in params) {
+ this.chart.measure = params.measure;
+ }
+ if ('timeRanges' in params) {
+ this.chart.timeRanges = params.timeRanges;
+ }
+
+ this._computeDerivedParams();
+
+ if ('mode' in params) {
+ this.chart.mode = params.mode;
+ return Promise.resolve();
+ }
+ if ('stacked' in params) {
+ this.chart.stacked = params.stacked;
+ return Promise.resolve();
+ }
+ if ('orderBy' in params) {
+ this.chart.orderBy = params.orderBy;
+ return Promise.resolve();
+ }
+ return this._loadGraph();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Compute this.chart.processedGroupBy, this.chart.domains, this.chart.origins,
+ * and this.chart.comparisonFieldIndex.
+ * Those parameters are determined by this.chart.timeRanges, this.chart.groupBy, and this.chart.domain.
+ *
+ * @private
+ */
+ _computeDerivedParams: function () {
+ this.chart.processedGroupBy = this._processGroupBy(this.chart.groupBy);
+
+ const { range, rangeDescription, comparisonRange, comparisonRangeDescription, fieldName } = this.chart.timeRanges;
+ if (range) {
+ this.chart.domains = [
+ this.chart.domain.concat(range),
+ this.chart.domain.concat(comparisonRange),
+ ];
+ this.chart.origins = [rangeDescription, comparisonRangeDescription];
+ const groupBys = this.chart.processedGroupBy.map(function (gb) {
+ return gb.split(":")[0];
+ });
+ this.chart.comparisonFieldIndex = groupBys.indexOf(fieldName);
+ } else {
+ this.chart.domains = [this.chart.domain];
+ this.chart.origins = [""];
+ this.chart.comparisonFieldIndex = -1;
+ }
+ },
+ /**
+ * @override
+ */
+ _isEmpty() {
+ return this.chart.dataPoints.length === 0;
+ },
+ /**
+ * Fetch and process graph data. It is basically a(some) read_group(s)
+ * with correct fields for each domain. We have to do some light processing
+ * to separate date groups in the field list, because they can be defined
+ * with an aggregation function, such as my_date:week.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _loadGraph: function () {
+ var self = this;
+ this.chart.dataPoints = [];
+ var groupBy = this.chart.processedGroupBy;
+ var fields = _.map(groupBy, function (groupBy) {
+ return groupBy.split(':')[0];
+ });
+
+ if (this.chart.measure !== '__count__') {
+ if (this.fields[this.chart.measure].type === 'many2one') {
+ fields = fields.concat(this.chart.measure + ":count_distinct");
+ }
+ else {
+ fields = fields.concat(this.chart.measure);
+ }
+ }
+
+ var context = _.extend({fill_temporal: true}, this.chart.context);
+
+ var proms = [];
+ this.chart.domains.forEach(function (domain, originIndex) {
+ proms.push(self._rpc({
+ model: self.modelName,
+ method: 'read_group',
+ context: context,
+ domain: domain,
+ fields: fields,
+ groupBy: groupBy,
+ lazy: false,
+ }).then(self._processData.bind(self, originIndex)));
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * Since read_group is insane and returns its result on different keys
+ * depending of some input, we have to normalize the result.
+ * Each group coming from the read_group produces a dataPoint
+ *
+ * @todo This is not good for race conditions. The processing should get
+ * the object this.chart in argument, or an array or something. We want to
+ * avoid writing on a this.chart object modified by a subsequent read_group
+ *
+ * @private
+ * @param {number} originIndex
+ * @param {any} rawData result from the read_group
+ */
+ _processData: function (originIndex, rawData) {
+ var self = this;
+ var isCount = this.chart.measure === '__count__';
+ var labels;
+
+ function getLabels (dataPt) {
+ return self.chart.processedGroupBy.map(function (field) {
+ return self._sanitizeValue(dataPt[field], field.split(":")[0]);
+ });
+ }
+ rawData.forEach(function (dataPt){
+ labels = getLabels(dataPt);
+ var count = dataPt.__count || dataPt[self.chart.processedGroupBy[0]+'_count'] || 0;
+ var value = isCount ? count : dataPt[self.chart.measure];
+ if (value instanceof Array) {
+ // when a many2one field is used as a measure AND as a grouped
+ // field, bad things happen. The server will only return the
+ // grouped value and will not aggregate it. Since there is a
+ // name clash, we are then in the situation where this value is
+ // an array. Fortunately, if we group by a field, then we can
+ // say for certain that the group contains exactly one distinct
+ // value for that field.
+ value = 1;
+ }
+ self.chart.dataPoints.push({
+ resId: dataPt[self.chart.groupBy[0]] instanceof Array ? dataPt[self.chart.groupBy[0]][0] : -1,
+ count: count,
+ domain: dataPt.__domain,
+ value: value,
+ labels: labels,
+ originIndex: originIndex,
+ });
+ });
+ },
+ /**
+ * Process the groupBy parameter in order to keep only the finer interval option for
+ * elements based on date/datetime field (e.g. 'date:year'). This means that
+ * 'week' is prefered to 'month'. The field stays at the place of its first occurence.
+ * For instance,
+ * ['foo', 'date:month', 'bar', 'date:week'] becomes ['foo', 'date:week', 'bar'].
+ *
+ * @private
+ * @param {string[]} groupBy
+ * @returns {string[]}
+ */
+ _processGroupBy: function(groupBy) {
+ const groupBysMap = new Map();
+ for (const gb of groupBy) {
+ let [fieldName, interval] = gb.split(':');
+ const field = this.fields[fieldName];
+ if (['date', 'datetime'].includes(field.type)) {
+ interval = interval || DEFAULT_INTERVAL;
+ }
+ if (groupBysMap.has(fieldName)) {
+ const registeredInterval = groupBysMap.get(fieldName);
+ if (rankInterval(registeredInterval) < rankInterval(interval)) {
+ groupBysMap.set(fieldName, interval);
+ }
+ } else {
+ groupBysMap.set(fieldName, interval);
+ }
+ }
+ return [...groupBysMap].map(([fieldName, interval]) => {
+ if (interval) {
+ return `${fieldName}:${interval}`;
+ }
+ return fieldName;
+ });
+ },
+ /**
+ * Helper function (for _processData), turns various values in a usable
+ * string form, that we can display in the interface.
+ *
+ * @private
+ * @param {any} value value for the field fieldName received by the read_group rpc
+ * @param {string} fieldName
+ * @returns {string}
+ */
+ _sanitizeValue: function (value, fieldName) {
+ if (value === false && this.fields[fieldName].type !== 'boolean') {
+ return _t("Undefined");
+ }
+ if (value instanceof Array) {
+ return value[1];
+ }
+ if (fieldName && (this.fields[fieldName].type === 'selection')) {
+ var selected = _.where(this.fields[fieldName].selection, {0: value})[0];
+ return selected ? selected[1] : value;
+ }
+ return value;
+ },
+});
+
+});