[IMP] More dataset refactoring
[odoo/odoo.git] / addons / web / static / src / js / data_import.js
1 openerp.web.data_import = function(openerp) {
2 var QWeb = openerp.web.qweb,
3     _t = openerp.web._t;
4 /**
5  * Safari does not deal well at all with raw JSON data being returned. As a
6  * result, we're going to cheat by using a pseudo-jsonp: instead of getting
7  * JSON data in the iframe, we're getting a ``script`` tag which consists of a
8  * function call and the returned data (the json dump).
9  *
10  * The function is an auto-generated name bound to ``window``, which calls
11  * back into the callback provided here.
12  *
13  * @param {Object} form the form element (DOM or jQuery) to use in the call
14  * @param {Object} attributes jquery.form attributes object
15  * @param {Function} callback function to call with the returned data
16  */
17 function jsonp(form, attributes, callback) {
18     attributes = attributes || {};
19     var options = {jsonp: _.uniqueId('import_callback_')};
20     window[options.jsonp] = function () {
21         delete window[options.jsonp];
22         callback.apply(null, arguments);
23     };
24     if ('data' in attributes) {
25         _.extend(attributes.data, options);
26     } else {
27         _.extend(attributes, {data: options});
28     }
29     $(form).ajaxSubmit(attributes);
30 }
31
32 openerp.web.DataImport = openerp.web.Dialog.extend({
33     template: 'ImportDataView',
34     dialog_title: {toString: function () { return _t("Import Data"); }},
35     init: function(parent, dataset){
36         var self = this;
37         this._super(parent, {});
38         this.model = parent.model;
39         this.fields = [];
40         this.all_fields = [];
41         this.fields_with_defaults = [];
42         this.required_fields = null;
43
44         var convert_fields = function (root, prefix) {
45             prefix = prefix || '';
46             _(root.fields).each(function (f) {
47                 self.all_fields.push(prefix + f.name);
48                 if (f.fields) {
49                     convert_fields(f, prefix + f.id + '/');
50                 }
51             });
52         };
53         this.ready  = $.Deferred.queue().then(function () {
54             self.required_fields = _(self.fields).chain()
55                 .filter(function (field) {
56                     return field.required &&
57                            !_.include(self.fields_with_defaults, field.id); })
58                 .pluck('name')
59                 .value();
60             convert_fields(self);
61             self.all_fields.sort();
62         });
63     },
64     start: function() {
65         var self = this;
66         this._super();
67         this.open({
68             buttons: [
69                 {text: _t("Close"), click: function() { self.stop(); }},
70                 {text: _t("Import File"), click: function() { self.do_import(); }, 'class': 'oe-dialog-import-button'}
71             ],
72             close: function(event, ui) {
73                 self.stop();
74             }
75         });
76         this.toggle_import_button(false);
77         this.$element.find('#csvfile').change(this.on_autodetect_data);
78         this.$element.find('fieldset').change(this.on_autodetect_data);
79         this.$element.delegate('fieldset legend', 'click', function() {
80             $(this).parent().toggleClass('oe-closed');
81         });
82         this.ready.push(new openerp.web.DataSet(this, this.model).call(
83             'fields_get', [], function (fields) {
84                 self.graft_fields(fields);
85                 self.ready.push(new openerp.web.DataSet(self, self.model)
86                         .default_get(_.pluck(self.fields, 'id')).then(function (fields) {
87                     _.each(fields, function(val, key) {
88                         if (val) {
89                             self.fields_with_defaults.push(key);
90                         }
91                     });
92                 })
93             )
94         }));
95     },
96     graft_fields: function (fields, parent, level) {
97         parent = parent || this;
98         level = level || 0;
99
100         var self = this;
101         if (level === 0) {
102             parent.fields.push({
103                 id: 'id',
104                 name: 'id',
105                 string: _t('External ID'),
106                 required: false
107             });
108         }
109         _(fields).each(function (field, field_name) {
110             // Ignore spec for id field
111             // Don't import function fields (function and related)
112             if (field_name === 'id' || 'function' in field) {
113                 return;
114             }
115             var f = {
116                 id: field_name,
117                 name: field_name,
118                 string: field.string,
119                 required: field.required
120             };
121
122             switch (field.type) {
123             case 'many2many':
124             case 'many2one':
125                 f.name += '/id';
126                 break;
127             case 'one2many':
128                 f.name += '/id';
129                 f.fields = [];
130                 // only fetch sub-fields to a depth of 2 levels
131                 if (level < 2) {
132                     self.ready.push(new openerp.web.DataSet(self, field.relation).call(
133                         'fields_get', [], function (fields) {
134                             self.graft_fields(fields, f, level+1);
135                     }));
136                 }
137                 break;
138             }
139             parent.fields.push(f);
140         });
141     },
142     toggle_import_button: function (newstate) {
143         this.$element.dialog('widget')
144                 .find('.oe-dialog-import-button')
145                 .button('option', 'disabled', !newstate);
146     },
147     do_import: function() {
148         if(!this.$element.find('#csvfile').val()) { return; }
149         var lines_to_skip = parseInt(this.$element.find('#csv_skip').val(), 10);
150         var with_headers = this.$element.find('#file_has_headers').prop('checked');
151         if (!lines_to_skip && with_headers) {
152             lines_to_skip = 1;
153         }
154         var indices = [], fields = [];
155         this.$element.find(".sel_fields").each(function(index, element) {
156             var val = element.value;
157             if (!val) {
158                 return;
159             }
160             indices.push(index);
161             fields.push(val);
162         });
163
164         jsonp(this.$element.find('#import_data'), {
165             url: '/web/import/import_data',
166             data: {
167                 model: this.model,
168                 meta: JSON.stringify({
169                     skip: lines_to_skip,
170                     indices: indices,
171                     fields: fields
172                 })
173             }
174         }, this.on_import_results);
175     },
176     on_autodetect_data: function() {
177         if(!this.$element.find('#csvfile').val()) { return; }
178         jsonp(this.$element.find('#import_data'), {
179             url: '/web/import/detect_data'
180         }, this.on_import_results);
181     },
182     on_import_results: function(results) {
183         this.$element.find('#result').empty();
184         var headers, result_node = this.$element.find("#result");
185
186         if (results['error']) {
187             result_node.append(QWeb.render('ImportView.error', {
188                 'error': results['error']}));
189             this.$element.find('fieldset').removeClass('oe-closed');
190             return;
191         }
192         if (results['success']) {
193             if (this.widget_parent.widget_parent.active_view == "list") {
194                 this.widget_parent.reload_content();
195             }
196             this.stop();
197             return;
198         }
199
200         if (results['records']) {
201             var lines_to_skip = parseInt(this.$element.find('#csv_skip').val(), 10),
202                 with_headers = this.$element.find('#file_has_headers').prop('checked');
203             headers = with_headers ? results.records[0] : null;
204
205             result_node.append(QWeb.render('ImportView.result', {
206                 'headers': headers,
207                 'records': lines_to_skip ? results.records.slice(lines_to_skip)
208                           : with_headers ? results.records.slice(1)
209                           : results.records
210             }));
211             this.$element.find('fieldset').addClass('oe-closed');
212         }
213         this.$element.find('form').removeClass('oe-import-no-result');
214
215         this.$element.delegate('.oe-m2o-drop-down-button', 'click', function () {
216             $(this).prev('input').focus();
217         });
218
219         var self = this;
220         this.ready.then(function () {
221             var $fields = self.$element.find('.sel_fields').bind('blur', function () {
222                 if (this.value && !_(self.all_fields).contains(this.value)) {
223                     this.value = '';
224                 }
225             }).autocomplete({
226                 minLength: 0,
227                 source: self.all_fields,
228                 change: self.on_check_field_values
229             }).focus(function () {
230                 $(this).autocomplete('search');
231             });
232             // Column auto-detection
233             _(headers).each(function (header, index) {
234                 var field_name = self.match_column_to_field(header);
235                 if (field_name) {
236                     $fields.eq(index).val(field_name);
237                 }
238             });
239             self.on_check_field_values();
240         });
241     },
242     /**
243      * Returns the name of the field (nested) matching the provided column name
244      *
245      * @param {String} name column name to look for
246      * @param {Array} [fields] fields to look into for the provided name
247      * @returns {String|undefined}
248      */
249     match_column_to_field: function (name, fields) {
250         fields = fields || this.fields;
251         var f;
252         f = _(fields).detect(function (field) {
253             // TODO: levenshtein between header and field.string
254             return field.name === name
255                 || field.string.toLowerCase() === name.toLowerCase();
256         });
257         if (f) { return f.name; }
258
259         // if ``name`` is a path (o2m), we need to recurse through its .fields
260         var index = name.indexOf('/');
261         if (index === -1) { return undefined; }
262         // Get the first path section, try to find the matching field
263         var column_name = name.substring(0, index);
264         f = _(fields).detect(function (field) {
265             // field.name for o2m is $foo/id, so we want to match on id
266             return field.id === column_name
267                 || field.string.toLowerCase() === column_name.toLowerCase()
268         });
269         if (!f) { return undefined; }
270
271         // if we found a matching field for the first path section, recurse in
272         // its own .fields to try and get the rest of the path matched
273         var rest = this.match_column_to_field(
274                 name.substring(index+1), f.fields);
275         if (!rest) { return undefined; }
276         return f.id + '/' + rest;
277     },
278     /**
279      * Looks through all the field selections, and tries to find if two
280      * (or more) columns were matched to the same model field.
281      *
282      * Returns a map of the multiply-mapped fields to an array of offending
283      * columns (not actually columns, but the inputs containing the same field
284      * names).
285      *
286      * Also has the side-effect of marking the discovered inputs with the class
287      * ``duplicate_fld``.
288      *
289      * @returns {Object<String, Array<String>>} map of duplicate field matches to same-valued inputs
290      */
291     find_duplicate_fields: function() {
292         // Maps values to DOM nodes, in order to discover duplicates
293         var values = {}, duplicates = {};
294         this.$element.find(".sel_fields").each(function(index, element) {
295             var value = element.value;
296             var $element = $(element).removeClass('duplicate_fld');
297             if (!value) { return; }
298
299             if (!(value in values)) {
300                 values[value] = element;
301             } else {
302                 var same_valued_field = values[value];
303                 if (value in duplicates) {
304                     duplicates[value].push(element);
305                 } else {
306                     duplicates[value] = [same_valued_field, element];
307                 }
308                 $element.add(same_valued_field).addClass('duplicate_fld');
309             }
310         });
311         return duplicates;
312     },
313     on_check_field_values: function () {
314         this.$element.find("#message, #msg").remove();
315
316         var required_valid = this.check_required();
317
318         var duplicates = this.find_duplicate_fields();
319         if (_.isEmpty(duplicates)) {
320             this.toggle_import_button(required_valid);
321         } else {
322             var $err = $('<div id="msg" style="color: red;">Destination fields should only be selected once, some fields are selected more than once:</div>').insertBefore(this.$element.find('#result'));
323             var $dupes = $('<dl>').appendTo($err);
324             _(duplicates).each(function(elements, value) {
325                 $('<dt>').text(value).appendTo($dupes);
326                 _(elements).each(function(element) {
327                     var cell = $(element).closest('td');
328                     $('<dd>').text(cell.parent().children().index(cell)).appendTo($dupes);
329                 });
330             });
331             this.toggle_import_button(false);
332         }
333
334     },
335     check_required: function() {
336         if (!this.required_fields.length) { return true; }
337
338         var selected_fields = _(this.$element.find('.sel_fields').get()).chain()
339             .pluck('value')
340             .compact()
341             .value();
342
343         var missing_fields = _.difference(this.required_fields, selected_fields);
344         if (missing_fields.length) {
345             this.$element.find("#result").before('<div id="message" style="color:red">*Required Fields are not selected : ' + missing_fields + '.</div>');
346             return false;
347         }
348         return true;
349     },
350     stop: function() {
351         this.$element.remove();
352         this._super();
353     }
354 });
355 };