1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
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;
},
});
});
|