[REF] base_import: revamp import module
Purpose ======= Propose an import tool that is more intuitive and allows more import options to ease the import. Specifications ============== Update upload import file screen -------------------------------- - Relabel import welcome screen - relabel the 'load file' button to 'upload file' - relabel the first (bold) line of the helper to 'Upload an Excel or CSV file to import' - Move all import options in a left panel - Invert rows and columns in the mapping view - One row for each column (header) to map in the imported file - Columns: File Column / Odoo Field / If a match cannot be found / Comments Left panel details ------------------ The sidepanel to the left of the mapping view is composed as followed: - Imported File - Import file name - Sheet: Dropdown to select the sheet in the file if there are more than 1. - 'use first row as header' checkbox: like the existing option, defines whether the first row of the file should be considered as the header. True by default. If False, display the first value only under 'File Column' (see above) - Formatting: only available if the file is a .csv - list every option that already existed in previous import implementation. - Batch Import - is only available in debug moide and if the file exceeds the batch limit - Batch limit : 2000 - Allow to define the threshold and the batch size. - Help - Download Template link - Go to FAQ link - Advanced - Track history during import. (same as existing functionallity) - Show Fields of relation fields. (same as existing functionallity) Columns details --------------- - File Column: - Contains the column headers of the import file and the first non-empty value of the column as a 'subtitle' (in grey italic) - If 'use first row as header' is False, only display the first column value (without the grey italic) - Odoo Field: - Contains dropdowns to the fields of the current model - Required fields are diplayed in bold in the dropdown - Icon: In the front of the field label, the field type (char, many2one,..) is indicated by an icon. - Tooltip: when hovering a field, display a tooltip with the following information: field label, technical name, type, related model (if any) - Placeholder: If no field is selected, display the placeholder 'To import, select a field...' in text-warning bold (i.e. orange) - Allow clear: there is a close (fa-times) icon at the end of the dropdown body to remove the Odoo field (and prevent import of this column) - Multi Mapping: when setting a field already matched to another column, unset it from the 'old' column (NB: exception made for char/text fields) - Comments: - Contains feedback from the system to the user, either import errors/warnings or any additional details (multi mapping comments,...) - For many2many field, display in related comment cell the following: "To import multiple values, separate them by a comma". - If there is an import error for a specific field, the error will be displayed in the comment cell of the related field. - If there is a mapping error, mapping options are displayed under the error div to let the user choose the best option to fix that error. Import errors management ------------------------ - Errors/warnings that can be matched to a specific field are displayed as alert-danger/warning in the corresponding 'Comments' cell of the field. - For 'no match found' errors, if X values couldn't be found, display an unique error box will all the errors. - Values beyond the first three are folded under a 'More' button. - No changes on the 'See possible values' button. - If an unmatched value is present in one row only, display : '<value> at row X (<name of the row if the name field is matched>)' - If an unmatched value is multiple rows, display: '<value> at multiple rows' - Errors/warnings/infos that cannot be matched to specific field are displayed above the mapping listview. Global errors are not regrouped by error types to avoid too much code complexy just to handle the rare times where multiple errors of same types cannot be linked to a specific mapped field. BaseImportError class have been introduced to ease the formatting of the various exceptions that can occur during the import. After testing the import, display the following above the mapping table: - No warning/error: 'Everything seems valid' (alert-info) - At least one error: 'The file contains blocking errors (see below)' (alert-error) - At least one warning but no error: 'The file contains non-blocking warning (see below)' (alert-warning) Mapping options --------------- Mapping options are only displayed after testing or importing the file, if there are import errors. The goal is to have a clean interface and to guide the user step by step. The import process can therefore be a bit longer as it needs to import -> choose solution for errors -> re-import but that is easier for users to learn and understand this reworked import tool. Possible values when a value cannot be matched: - For many2one / many2many fields: - Prevent import: (selected by default) not finding a match is blocking the import - Skip unknown values: values that cannot be matched will be skipped. (hidden if the field is required) - Create new values: Create records for values that cannot be matched - For selection fields: - Prevent import (selected by default) - Skip unknown values: (hidden if the field is required) - Set to <first value>: if cannot be matched, set it to <first value> - Set to <second value> - Set to <third value> - etc. - For boolean fields: - Prevent Import (selected by default) - Set to True - Set to False Note: With this rework, boolean warnings where the system assumes the replacement value in case of matching error is removed and is replaced by a blocking error. The user now has to choose the value to set. "Prevent import" is the default behaviour when testing or importing. Automatic mapping proposal --------------------------- When loading a file, an automated mapping is directly proposed to the user, based on word distance (see below), and on mapping created on previous imports. - Priority is given for mapping created on previous imports, skip fuzzy mapping. - a distance of -1 is used to ensure priority during duplicates removal . - In case multiple headers are mapped on the same field, if the mapped field is already taken by another header, use fuzzy mapping instead. - If no previous mapping for that header on that model, try an exact match on every field id and field name of the model. (distance = 0) - If no match is found, fuzzy mapping is applied (word distance). The fuzzy mapping is executed only on the most likely fields (see below). - Remove duplicates: keep the header-field couple that has the smallest distance. In case of equality, keep the first. Automatic mapping is therefore optimised for previous mapping or exact match, as fuzzy mapping requires heaviest treatment. This is intended to prioritise the import of files based on import templates. Most likely fields ------------------ When parsing the import file, each header is analysed to guess what type of data the column contains. For example, ff the column contains float, we suppose that that header will most likely be matched on float or monetary fields. The most likely fields are a subset of the model's fields that match the header types. Most likely fields are used for the fuzzy mapping, to propose the user a field mapping based on the header types, if an exact match could not be found. Most likely fields are also listed under "Suggested fields" in the mapping dropdown in "Odoo Field" column of the import tool. For now on, every fields (no mather their type) can be matched to any header, but we prioritise the most likely fields. This way, we don't constrain the mapping possibility based on what we suppose the user would do, but we instead guide suggest the user the most likely mapping solutions. Word distance mapping ---------------------------- In order to improve the mapping configuration, if an exact match cannot be found between the file column and one of the odoo field: - Use Word distance: Word distance return a indice between 0 and 1. - 0: exact match - 1: completely different - A: First try on field['name'] - B: Then on field['string'] - Keep the minimal distance between A and B for each odoo_field - C: Keep the field that has the minimal distance. - Match the column to the field by default if C['distance'] < 0.3. Note: 0.3 has been chosen to ensure proximity but still having a little error margin. Multi mapping ------------- When multiple file columns are matched to the same char/text/many2many field, display the following alert-info box in the "Comments" column of the related fields: "Those columns will be concatenated in field <field label>" Multi mapping rule : - If it is a char field, separate the concatenated values by a space - If it is a text field, separate the concatenated values by a line break - If it is a many2many field, separate the concatenated values by a comma Various improvements -------------------- - During Import (or Test), the first waiting message displayed has been modified to 'Importing...' or 'Testing...'. The progress (x record imported/tested) is only displayed after the first batch of record have been imported/tested. - Ease matching for Selection field: use case insensitive comparaison instead of exact match. - Add filename to import wizard. - Add placeholder to search input of field mapping dropdown. Tests have been adapted accordingly. Links ===== Task ID: 2352241 closes odoo/odoo#61948 Related: odoo/enterprise#17246 Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
@@ -22,6 +22,7 @@ Re-implement Odoo's file import system:
|
||||
need or want an online import can avoid it being available to users.
|
||||
""",
|
||||
'depends': ['web'],
|
||||
'version': '2.0',
|
||||
'category': 'Hidden/Tools',
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
@@ -34,6 +35,7 @@ Re-implement Odoo's file import system:
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'base_import/static/src/scss/base_import.scss',
|
||||
'base_import/static/src/scss/icons.scss',
|
||||
'base_import/static/lib/javascript-state-machine/state-machine.js',
|
||||
'base_import/static/src/js/import_action.js',
|
||||
'base_import/static/src/js/import_menu.js',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 216 B |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
@@ -94,19 +94,15 @@ var DataImport = AbstractAction.extend({
|
||||
{name: 'float_decimal_separator', label: _lt("Decimal Separator:"), value: '.'}
|
||||
],
|
||||
events: {
|
||||
// 'change .oe_import_grid input': 'import_dryrun',
|
||||
'change .oe_import_file': 'loaded_file',
|
||||
'change input.oe_import_has_header, .js_import_options input': 'settings_changed',
|
||||
'change input.oe_import_advanced_mode': function (e) {
|
||||
this.do_not_change_match = true;
|
||||
this['settings_changed']();
|
||||
},
|
||||
'click a.oe_import_toggle': function (e) {
|
||||
e.preventDefault();
|
||||
this.$('.oe_import_options').toggle();
|
||||
},
|
||||
'click .oe_import_report a.oe_import_report_count': function (e) {
|
||||
e.preventDefault();
|
||||
$(e.target).parent().find('i.arrow').toggleClass('up down');
|
||||
$(e.target).parent().parent().toggleClass('oe_import_report_showmore');
|
||||
},
|
||||
'click .oe_import_report_see_possible_value': function (e) {
|
||||
@@ -150,6 +146,7 @@ var DataImport = AbstractAction.extend({
|
||||
this._title = _t('Import a File'); // Displayed in the breadcrumbs
|
||||
this.do_not_change_match = false;
|
||||
this.sheets = [];
|
||||
this.selectionFields = {}; // Used to compute fallback values in backend.
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
@@ -214,6 +211,7 @@ var DataImport = AbstractAction.extend({
|
||||
width: '50%',
|
||||
data: _.map(('utf-8 utf-16 windows-1252 latin1 latin2 big5 gb18030 shift_jis windows-1251 koir8_r').split(/\s+/), _make_option),
|
||||
query: dataFilteredQuery,
|
||||
minimumResultsForSearch: -1,
|
||||
initSelection: function ($e, c) {
|
||||
return c(_make_option($e.val()));
|
||||
}
|
||||
@@ -230,6 +228,7 @@ var DataImport = AbstractAction.extend({
|
||||
width: '50%',
|
||||
data: data,
|
||||
query: dataFilteredQuery,
|
||||
minimumResultsForSearch: -1,
|
||||
// this is not provided to initSelection so can't use this.data
|
||||
initSelection: function ($e, c) {
|
||||
c(_from_data(data, $e.val()) || _make_option($e.val()))
|
||||
@@ -246,6 +245,7 @@ var DataImport = AbstractAction.extend({
|
||||
width: '50%',
|
||||
data: data_digits,
|
||||
query: dataFilteredQuery,
|
||||
minimumResultsForSearch: -1,
|
||||
initSelection: function ($e, c) {
|
||||
c(_from_data(data_digits, $e.val()) || _make_option($e.val()))
|
||||
}
|
||||
@@ -254,6 +254,7 @@ var DataImport = AbstractAction.extend({
|
||||
width: '50%',
|
||||
data: data_decimal,
|
||||
query: dataFilteredQuery,
|
||||
minimumResultsForSearch: -1,
|
||||
initSelection: function ($e, c) {
|
||||
c(_from_data(data_decimal, $e.val()) || _make_option($e.val()))
|
||||
}
|
||||
@@ -284,6 +285,7 @@ var DataImport = AbstractAction.extend({
|
||||
width: '50%',
|
||||
data: data,
|
||||
query: dataFilteredQuery,
|
||||
minimumResultsForSearch: -1,
|
||||
initSelection: function ($e, c) {
|
||||
c(_from_data(data, $e.val()) || _make_option($e.val()));
|
||||
}
|
||||
@@ -292,23 +294,25 @@ var DataImport = AbstractAction.extend({
|
||||
setup_sheets_picker: function () {
|
||||
var data = this.sheets.map(_make_option);
|
||||
this.$('input.oe_import_sheet').select2({
|
||||
width: '50%',
|
||||
width: '100%',
|
||||
data: data,
|
||||
query: dataFilteredQuery,
|
||||
minimumResultsForSearch: -1,
|
||||
initSelection: function ($e, c) {
|
||||
c(_from_data(data, $e.val()) || _make_option($e.val()))
|
||||
},
|
||||
minimumResultsForSearch: 10,
|
||||
});
|
||||
},
|
||||
|
||||
import_options: function () {
|
||||
var self = this;
|
||||
var options = {
|
||||
headers: this.$('input.oe_import_has_header').prop('checked'),
|
||||
has_headers: this.$('input.oe_import_has_header').prop('checked'),
|
||||
advanced: this.$('input.oe_import_advanced_mode').prop('checked'),
|
||||
keep_matches: this.do_not_change_match,
|
||||
name_create_enabled_fields: {},
|
||||
import_skip_fields: [],
|
||||
fallback_values: {},
|
||||
// start at row 1 = skip 0 lines
|
||||
skip: Number(this.$('#oe_import_row_start').val()) - 1 || 0,
|
||||
limit: Number(this.$('#oe_import_batch_limit').val()) || null,
|
||||
@@ -323,17 +327,32 @@ var DataImport = AbstractAction.extend({
|
||||
_(this.parse_opts_separators).each(function (opt) {
|
||||
options[opt.name] = self.$('input.oe_import_' + opt.name).val();
|
||||
});
|
||||
|
||||
options['fields'] = [];
|
||||
if (this.do_not_change_match) {
|
||||
options['fields'] = this.$('.oe_import_fields input.oe_import_match_field').map(function (index, el) {
|
||||
options['fields'] = this.$('input.oe_import_match_field').map(function (index, el) {
|
||||
return $(el).select2('val') || false;
|
||||
}).get();
|
||||
}
|
||||
this.do_not_change_match = false;
|
||||
this.$('input.o_import_create_option').each(function () {
|
||||
this.$('select.o_import_create_option').each(function () {
|
||||
var field = this.getAttribute('field');
|
||||
var type = this.getAttribute('type');
|
||||
if (field) {
|
||||
options.name_create_enabled_fields[field] = this.checked;
|
||||
if (['many2one', "many2many"].includes(type)) {
|
||||
if (this.value === 'skip') {
|
||||
options.import_skip_fields.push(field);
|
||||
} else {
|
||||
options.name_create_enabled_fields[field] = this.value === 'create';
|
||||
}
|
||||
// for selection, include also 'skip' that will be interpreted as 'None' in backend.
|
||||
} else if (['boolean','selection'].includes(type) && this.value !== 'prevent') {
|
||||
options.fallback_values[field] = {
|
||||
fallback_value: this.value,
|
||||
field_model: this.getAttribute('model'),
|
||||
field_type: type
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
return options;
|
||||
@@ -356,10 +375,18 @@ var DataImport = AbstractAction.extend({
|
||||
var import_toggle = false;
|
||||
var file = this.$('input.oe_import_file')[0].files[0];
|
||||
// some platforms send text/csv, application/csv, or other things if Excel is prevent
|
||||
if ((file.type && _.last(file.type.split('/')) === "csv") || ( _.last(file.name.split('.')) === "csv")) {
|
||||
import_toggle = true;
|
||||
}
|
||||
this.$form.find('.oe_import_box').toggle(import_toggle);
|
||||
var isCSV = ((file.type && _.last(file.type.split('/')) === "csv") || ( _.last(file.name.split('.')) === "csv"))
|
||||
this.$form.find('.o_import_formatting').toggleClass('d-none', !isCSV);
|
||||
|
||||
// get file name and extension separately, to apply ellipsis to the name and still keep the extension visible.
|
||||
// E.g. : superLongFileName.csv -> "superLongFi....csv" instead of "superLongFileNa..."
|
||||
var fileName = file.name.split('.');
|
||||
var fileExtension = fileName.pop();
|
||||
fileName = fileName.join('.');
|
||||
this.$('#oe_imported_file').text(fileName);
|
||||
this.$('#oe_imported_file_extension').text('.' + fileExtension);
|
||||
|
||||
this.$form.find('.oe_import_box').toggle(true);
|
||||
jsonp(this.$form, {
|
||||
url: '/base_import/set_file'
|
||||
}, this.proxy('settings_changed'));
|
||||
@@ -371,12 +398,20 @@ var DataImport = AbstractAction.extend({
|
||||
// TODO: test that write // succeeded?
|
||||
this.$form.removeClass('oe_import_preview_error oe_import_error');
|
||||
this.$form.toggleClass(
|
||||
'oe_import_noheaders text-muted',
|
||||
'oe_import_noheaders',
|
||||
!this.$('input.oe_import_has_header').prop('checked'));
|
||||
|
||||
// Clear the input value to allow onchange to be triggered
|
||||
// if the file is the same (for all browsers)
|
||||
self.$('input.oe_import_file').val('');
|
||||
this.$('input.oe_import_file').val('');
|
||||
this.$('.oe_import_options_cell,.oe_import_options_header').addClass('d-none');
|
||||
|
||||
this._cleanComments();
|
||||
|
||||
// Block UI during loading file.
|
||||
$.blockUI({message: QWeb.render('Throbber')});
|
||||
$(document.body).addClass('o_ui_blocked');
|
||||
$('.oe_throbber_message').text(_t("Loading file..."));
|
||||
|
||||
this._rpc({
|
||||
model: 'base_import.import',
|
||||
@@ -386,6 +421,8 @@ var DataImport = AbstractAction.extend({
|
||||
}).then(function (result) {
|
||||
var signal = result.error ? 'preview_failed' : 'preview_succeeded';
|
||||
self[signal](result);
|
||||
$(document.body).removeClass('o_ui_blocked');
|
||||
$.unblockUI();
|
||||
});
|
||||
},
|
||||
onpreview_error: function (event, from, to, result) {
|
||||
@@ -408,7 +445,9 @@ var DataImport = AbstractAction.extend({
|
||||
this.$form.addClass('oe_import_preview');
|
||||
this.$('input.oe_import_advanced_mode').prop('checked', result.advanced_mode);
|
||||
this.$('.oe_import_grid').html(QWeb.render('ImportView.preview', result));
|
||||
|
||||
// Activate the batch configuration panel only of the file length > 100. (In order to let the user choose
|
||||
// the batch size even for medium size file. Could be useful to reduce the batch size for complex models).
|
||||
this.$('.o_import_batch').toggleClass('d-none', !(result.file_length > 100));
|
||||
this.$('.o_import_batch_alert').toggleClass('d-none', !result.batch);
|
||||
|
||||
var messages = [];
|
||||
@@ -438,9 +477,9 @@ var DataImport = AbstractAction.extend({
|
||||
// hide all "true debug" options when not in debug mode
|
||||
this.$('.oe_import_debug_option').toggleClass('d-none', !result.debug);
|
||||
|
||||
var $fields = this.$('.oe_import_fields input');
|
||||
var $fields = this.$('.oe_import_match_field');
|
||||
this.render_fields_matches(result, $fields);
|
||||
var data = this.generate_fields_completion(result);
|
||||
var data = this._generate_fields_completion(result);
|
||||
var item_finder = function (id, items) {
|
||||
items = items || data;
|
||||
for (var i=0; i < items.length; ++i) {
|
||||
@@ -456,81 +495,235 @@ var DataImport = AbstractAction.extend({
|
||||
return '';
|
||||
};
|
||||
$fields.each(function (k,v) {
|
||||
var filtered_data = self.generate_fields_completion(result, k);
|
||||
var $fieldInput = $(v);
|
||||
var filteredData = self._generate_fields_completion(result, k);
|
||||
|
||||
var $thing = $();
|
||||
var bind = function (d) {};
|
||||
if (config.isDebug()) {
|
||||
$thing = $(QWeb.render('ImportView.create_record_option')).insertAfter(v).hide();
|
||||
bind = function (data) {
|
||||
switch (data.type) {
|
||||
case 'many2one': case 'many2many':
|
||||
$thing.find('input').attr('field', data.id);
|
||||
$thing.show();
|
||||
break;
|
||||
default:
|
||||
$thing.find('input').attr('field', '').prop('checked', false);
|
||||
$thing.hide();
|
||||
}
|
||||
var updateFieldInformation = function (fieldInfo) {
|
||||
// Get the comment cell on the same row in the table.
|
||||
var $commentCell = $fieldInput.closest('tr.oe_import_grid-row').find('.oe_import_comment_cell');
|
||||
var $optionsDiv = $commentCell.find('.oe_import_options_div');
|
||||
var isRelational = fieldInfo.type === 'many2many' || fieldInfo.type === 'many2one';
|
||||
var showSkip = !fieldInfo.required && (isRelational || fieldInfo.type === 'selection');
|
||||
|
||||
// get options cell related to that field
|
||||
if (isRelational || fieldInfo.type === 'boolean') {
|
||||
$optionsDiv.empty();
|
||||
$optionsDiv.append(
|
||||
QWeb.render('ImportView.create_record_option', {
|
||||
data: fieldInfo,
|
||||
is_relational: isRelational,
|
||||
show_skip: showSkip,
|
||||
})
|
||||
);
|
||||
} else if (fieldInfo.type === 'selection') {
|
||||
self._rpc({
|
||||
model: fieldInfo.comodel_name || fieldInfo.model_name,
|
||||
method: 'fields_get',
|
||||
}).then(function (values) {
|
||||
var selectionField = fieldInfo.id.split('/').pop();
|
||||
var selectionLabels = values[selectionField]["selection"].map(value => value[1]);
|
||||
|
||||
$optionsDiv.empty();
|
||||
$optionsDiv.append(
|
||||
QWeb.render('ImportView.create_record_option', {
|
||||
data: fieldInfo,
|
||||
values: selectionLabels,
|
||||
show_skip: showSkip,
|
||||
})
|
||||
);
|
||||
self.selectionFields[data.id] = values;
|
||||
});
|
||||
} else if ($optionsDiv.find('.o_import_create_option').length == 1) {
|
||||
$optionsDiv.empty();
|
||||
}
|
||||
|
||||
// re-attribute comment cell to the new selected field
|
||||
$commentCell.attr('field', fieldInfo.id || "");
|
||||
$commentCell.attr('model', fieldInfo.comodel_name || fieldInfo.model_name || "");
|
||||
|
||||
// assign class for field icon
|
||||
var $fieldDropdown = $fieldInput.closest('.oe_import_match_cell').find('.select2-choice');
|
||||
var oldType = $fieldDropdown.getAttributes()['type'];
|
||||
$fieldDropdown.removeClass(`o_import_field_${oldType}`).addClass(`o_import_field_icon o_import_field_${fieldInfo.type}`);
|
||||
$fieldDropdown.attr('type', fieldInfo.type);
|
||||
};
|
||||
|
||||
var default_value = $fieldInput.val();
|
||||
var fieldInfo = item_finder(default_value);
|
||||
if (!fieldInfo) {
|
||||
$fieldInput.val('');
|
||||
}
|
||||
|
||||
$(v).select2({
|
||||
$fieldInput.select2({
|
||||
allowClear: true,
|
||||
minimumInputLength: 0,
|
||||
data: filtered_data,
|
||||
data: filteredData,
|
||||
initSelection: function (element, callback) {
|
||||
var default_value = element.val();
|
||||
if (!default_value) {
|
||||
callback('');
|
||||
return;
|
||||
}
|
||||
|
||||
var data = item_finder(default_value);
|
||||
bind(data);
|
||||
callback(data);
|
||||
updateFieldInformation(fieldInfo);
|
||||
callback(fieldInfo);
|
||||
self._handleMappingComments(v, fieldInfo);
|
||||
},
|
||||
placeholder: _t('Don\'t import'),
|
||||
// Format the tooltip.
|
||||
formatSelection: function (object, container) {
|
||||
var fieldTooltipString = `%(label)s: %(labelValue)s
|
||||
%(name)s: %(nameValue)s
|
||||
%(type)s: %(typeValue)s
|
||||
%(model)s: %(modelValue)s`;
|
||||
var tooltip = _.str.sprintf(fieldTooltipString, {
|
||||
'label': _t("Label"), 'labelValue': object.text,
|
||||
'name': _t("Name"), 'nameValue': object.id,
|
||||
'type': _t("Type"), 'typeValue': object.type,
|
||||
'model': _t("Model"), 'modelValue': object.comodel_name || object.model_name,
|
||||
});
|
||||
$(container[0]).closest('a').attr('title', tooltip);
|
||||
return object.text;
|
||||
},
|
||||
// For each choice, if field is required, make it bold in the list
|
||||
formatResultCssClass: function (object) {
|
||||
if (object.required) { return "font-weight-bold text-decoration-underline"; }
|
||||
return "";
|
||||
},
|
||||
placeholder: _t('To import, select a field...'),
|
||||
width: 'resolve',
|
||||
dropdownCssClass: 'oe_import_selector'
|
||||
}).on('change', function (e) {
|
||||
bind(item_finder(e.currentTarget.value));
|
||||
}).on('change', function (event) {
|
||||
var changedField = event.currentTarget;
|
||||
var fieldRemovedId = event.removed ? event.removed.id : false;
|
||||
self._cleanFieldComments(changedField, fieldRemovedId);
|
||||
|
||||
var fieldInfo = item_finder(changedField.value);
|
||||
updateFieldInformation(fieldInfo);
|
||||
self._handleMappingComments(changedField, fieldInfo);
|
||||
|
||||
if (!event.val) {
|
||||
var $matchingCell = $(changedField).closest('.oe_import_match_cell')
|
||||
$matchingCell.find('.o_import_field_icon').removeClass('o_import_field_icon');
|
||||
$matchingCell.find('a.select2-choice').removeAttr("title");
|
||||
}
|
||||
});
|
||||
|
||||
$fieldInput.closest('.oe_import_match_cell').find('.select2-input').attr('placeholder', _t('Search for a field...'));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* This method is called when changing the field to map with a file column, or when removing it.
|
||||
* It will clean the eventual comments, errors and the mapping options related to the previously selected field.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_cleanFieldComments: function (changedField, fieldRemovedId) {
|
||||
// Check that the column was not mapped to same field than another column
|
||||
if (fieldRemovedId) {
|
||||
var $sameMappedFields = self.$(`.oe_import_comment_cell[field=\"${fieldRemovedId}\"]`).find('.oe_import_same_mapped_field');
|
||||
if ($sameMappedFields.length == 2) {
|
||||
// remove all same mapped field comments
|
||||
$sameMappedFields.remove();
|
||||
}
|
||||
}
|
||||
// remove all comments for this header-field mapping row
|
||||
var $fieldRow = $(changedField).closest('tr.oe_import_grid-row');
|
||||
$fieldRow.find('.oe_import_comments_div').empty();
|
||||
$fieldRow.find('.oe_import_options_div').addClass("d-none");
|
||||
},
|
||||
|
||||
/**
|
||||
* This method is called when selecting a sheet or a new file to import, but also when running to import or the
|
||||
* import test. It will remove all the general comments and/or field specific warnings and errors
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_cleanComments: function () {
|
||||
this.$('.oe_import_error_report').empty();
|
||||
this.$('.oe_import_comments_div').find('.alert-error,.alert-warning').remove();
|
||||
this.$form.removeClass('oe_import_error');
|
||||
},
|
||||
|
||||
/**
|
||||
* This method is called when selecting a new mapping field, or when changing/removing an already selected mapping
|
||||
* field. It will check if multiple field columns are/were mapped on the same field.
|
||||
* 1. In case the field type is char or text:
|
||||
* This method will display / remove the comments informing the user that two columns content will be
|
||||
* concatenated on the field they have chosen.
|
||||
* 2. Else:
|
||||
* This method will remove the field mapping from the old column, as each field, except for the case 1., can only
|
||||
* be mapped once.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_handleMappingComments: function (changedField, fieldInfo) {
|
||||
// check if two columns are mapped on the same fields (for char/text fields)
|
||||
var commentsToAdd = [];
|
||||
var $sameMappedFields = self.$('.oe_import_comment_cell[field="' + fieldInfo.id + '"]');
|
||||
|
||||
if (fieldInfo.type == 'many2many') {
|
||||
commentsToAdd.push(QWeb.render('ImportView.comment_m2m_field'));
|
||||
}
|
||||
if ($sameMappedFields.length >= 2) {
|
||||
if (['char', 'text', "many2many"].includes(fieldInfo.type)) {
|
||||
commentsToAdd.push(QWeb.render('ImportView.comment_same_mapped_field', {
|
||||
field: fieldInfo.text,
|
||||
}));
|
||||
} else { // if column is mapped on an already mapped field, remove that field from the old column.
|
||||
var $targetMappedFieldId = $(changedField).parent().find('div.oe_import_match_field').getAttributes()['id'];
|
||||
_.each($sameMappedFields, function(fieldComment) {
|
||||
var $mappingCell = $(fieldComment).parent().find('div.oe_import_match_field');
|
||||
if ($mappingCell.getAttributes()['id'] !== $targetMappedFieldId) {
|
||||
$mappingCell.find('.select2-search-choice-close').trigger('mousedown').trigger('click');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var $commentDiv = $sameMappedFields.find(".oe_import_comments_div");
|
||||
$commentDiv.empty();
|
||||
_.each($commentDiv, function(fieldComment) {
|
||||
_.each(commentsToAdd, function(comment) {
|
||||
$(fieldComment).append(comment);
|
||||
});
|
||||
});
|
||||
},
|
||||
generate_fields_completion: function (root, index) {
|
||||
|
||||
_generate_fields_completion: function (root, index) {
|
||||
var self = this;
|
||||
var basic = [];
|
||||
var regulars = [];
|
||||
var o2m = [];
|
||||
var headers_type = root.headers_type;
|
||||
var suggested = [];
|
||||
var header_types = root.header_types;
|
||||
function traverse(field, ancestors, collection, type) {
|
||||
var subfields = field.fields;
|
||||
var advanced_mode = self.$('input.oe_import_advanced_mode').prop('checked');
|
||||
var field_path = ancestors.concat(field);
|
||||
var label = _(field_path).pluck('string').join(' / ');
|
||||
var id = _(field_path).pluck('name').join('/');
|
||||
if (type === undefined || (type !== undefined && (type.indexOf('all') !== -1 || type.indexOf(field['type']) !== -1))){
|
||||
// If non-relational, m2o or m2m, collection is regulars
|
||||
if (!collection) {
|
||||
if (field.name === 'id') {
|
||||
collection = basic;
|
||||
} else if (_.isEmpty(subfields)
|
||||
|| _.isEqual(_.pluck(subfields, 'name'), ['id', '.id'])) {
|
||||
collection = regulars;
|
||||
} else {
|
||||
collection = o2m;
|
||||
}
|
||||
// If non-relational, m2o or m2m, collection is either suggested if field type is in header_types, either regulars if type does not match
|
||||
if (!collection) {
|
||||
if (field.name === 'id') {
|
||||
collection = basic;
|
||||
} else if (_.isEmpty(subfields)
|
||||
|| _.isEqual(_.pluck(subfields, 'name'), ['id', '.id'])) {
|
||||
collection = (type && type.indexOf(field['type']) !== -1) ? suggested : regulars;
|
||||
} else {
|
||||
collection = o2m;
|
||||
}
|
||||
|
||||
collection.push({
|
||||
id: id,
|
||||
text: label,
|
||||
required: field.required,
|
||||
type: field.type
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
collection.push({
|
||||
id: id,
|
||||
text: label,
|
||||
required: field.required,
|
||||
type: field.type,
|
||||
default_value: field.default_value,
|
||||
comodel_name: field.comodel_name,
|
||||
model_name: field.model_name,
|
||||
});
|
||||
|
||||
if (advanced_mode){
|
||||
for(var i=0, end=subfields.length; i<end; ++i) {
|
||||
traverse(subfields[i], field_path, collection, type);
|
||||
@@ -546,7 +739,7 @@ var DataImport = AbstractAction.extend({
|
||||
traverse(field, [], undefined, ['all']);
|
||||
}
|
||||
else {
|
||||
traverse(field, [], undefined, headers_type[index]);
|
||||
traverse(field, [], undefined, header_types[index]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -555,19 +748,19 @@ var DataImport = AbstractAction.extend({
|
||||
return field1.text.localeCompare(field2.text);
|
||||
|
||||
};
|
||||
suggested.sort(cmp);
|
||||
regulars.sort(cmp);
|
||||
o2m.sort(cmp);
|
||||
if (!_.isEmpty(regulars) && !_.isEmpty(o2m)){
|
||||
if (!_.isEmpty(suggested)) {
|
||||
basic = basic.concat({ text: _t("Suggested Fields"), children: suggested });
|
||||
}
|
||||
basic = basic.concat([
|
||||
{ text: _t("Normal Fields"), children: regulars },
|
||||
{ text: !_.isEmpty(suggested) ? _t("Additional Fields") : _t("Standard Fields"), children: regulars },
|
||||
{ text: _t("Relation Fields"), children: o2m },
|
||||
]);
|
||||
}
|
||||
else if (!_.isEmpty(regulars)) {
|
||||
basic = basic.concat(regulars);
|
||||
}
|
||||
else if (!_.isEmpty(o2m)) {
|
||||
basic = basic.concat(o2m);
|
||||
} else {
|
||||
basic.concat(suggested, regulars, o2m);
|
||||
}
|
||||
return basic;
|
||||
},
|
||||
@@ -594,14 +787,14 @@ var DataImport = AbstractAction.extend({
|
||||
|
||||
//- import itself
|
||||
call_import: function (kwargs) {
|
||||
var fields = this.$('.oe_import_fields input.oe_import_match_field').map(function (index, el) {
|
||||
var fields = this.$('input.oe_import_match_field').map(function (index, el) {
|
||||
return $(el).select2('val') || false;
|
||||
}).get();
|
||||
var columns = this.$('.oe_import_grid-header .oe_import_grid-cell .o_import_header_name').map(function () {
|
||||
var columns = this.$('.o_import_header_name').map(function () {
|
||||
return $(this).text().trim().toLowerCase() || false;
|
||||
}).get();
|
||||
|
||||
var tracking_disable = 'tracking_disable' in kwargs ? kwargs.tracking_disable : !this.$('#oe_import_tracking').prop('checked')
|
||||
var tracking_disable = 'tracking_disable' in kwargs ? kwargs.tracking_disable : !this.$('#oe_import_tracking').prop('checked');
|
||||
delete kwargs.tracking_disable;
|
||||
kwargs.context = _.extend(
|
||||
{}, this.parent_context,
|
||||
@@ -619,7 +812,11 @@ var DataImport = AbstractAction.extend({
|
||||
var msg = kwargs.dryrun ? _t("%d records tested...")
|
||||
: _t("%d records successfully imported...");
|
||||
opts.callback = function (count) {
|
||||
$el.text(_.str.sprintf(msg, count));
|
||||
if (count) {
|
||||
$el.text(_.str.sprintf(msg, count));
|
||||
} else {
|
||||
$el.text(kwargs.dryrun ? _t("Testing...") : _t("Importing..."));
|
||||
}
|
||||
};
|
||||
|
||||
return this._batchedImport(opts, [this.id, fields, columns], kwargs, {done: 0, prev: 0})
|
||||
@@ -678,7 +875,7 @@ var DataImport = AbstractAction.extend({
|
||||
var self = this;
|
||||
return this._rpc({
|
||||
model: 'base_import.import',
|
||||
method: 'do',
|
||||
method: 'execute_import',
|
||||
args: args.concat([opts]),
|
||||
kwargs: kwargs
|
||||
}).then(function (results) {
|
||||
@@ -753,17 +950,20 @@ var DataImport = AbstractAction.extend({
|
||||
this.trigger_up('history_back');
|
||||
},
|
||||
onresults: function (event, from, to, results) {
|
||||
var fields = this.$('.oe_import_fields input.oe_import_match_field').map(function (index, el) {
|
||||
var self = this;
|
||||
var fields = this.$('input.oe_import_match_field').map(function (index, el) {
|
||||
return $(el).select2('val') || false;
|
||||
}).get();
|
||||
|
||||
var message = results.messages;
|
||||
var no_messages = _.isEmpty(message);
|
||||
var error_type = "warning";
|
||||
var errorMessages = results.messages;
|
||||
var no_messages = _.isEmpty(errorMessages);
|
||||
if (no_messages) {
|
||||
message.push({
|
||||
errorMessages.push({
|
||||
type: 'info',
|
||||
message: _t("Everything seems valid.")
|
||||
});
|
||||
error_type = false;
|
||||
} else if (event === 'import_failed' && results.ids) {
|
||||
// both ids in a failed import -> partial import
|
||||
this.toggle_partial(results);
|
||||
@@ -773,9 +973,10 @@ var DataImport = AbstractAction.extend({
|
||||
// display 1-indexed.
|
||||
var offset = 1;
|
||||
// offset more if header
|
||||
if (this.import_options().headers) { offset += 1; }
|
||||
if (this.import_options().has_headers) { offset += 1; }
|
||||
|
||||
var messagesSorted = _.sortBy(_(message).groupBy('message'), function (group) {
|
||||
// Sort errors by error gravity then by field
|
||||
var errorsSorted = _.sortBy(_(errorMessages).groupBy('message'), function (group) {
|
||||
if (group[0].priority){
|
||||
return -2;
|
||||
}
|
||||
@@ -783,7 +984,7 @@ var DataImport = AbstractAction.extend({
|
||||
// sort by gravity, then, order of field in list
|
||||
var order = 0;
|
||||
switch (group[0].type) {
|
||||
case 'error': order = 0; break;
|
||||
case 'error': order = 0; error_type = 'error'; break;
|
||||
case 'warning': order = fields.length + 1; break;
|
||||
case 'info': order = 2 * (fields.length + 1); break;
|
||||
default: order = 3 * (fields.length + 1); break;
|
||||
@@ -791,72 +992,145 @@ var DataImport = AbstractAction.extend({
|
||||
return order + _.indexOf(fields, group[0].field);
|
||||
});
|
||||
|
||||
this.$form.addClass('oe_import_error');
|
||||
this.$('.oe_import_error_report').html(
|
||||
QWeb.render('ImportView.error', {
|
||||
errors: messagesSorted,
|
||||
at: function (rows) {
|
||||
var from = rows.from + offset;
|
||||
var to = rows.to + offset;
|
||||
var rowName = '';
|
||||
if (results.name.length > rows.from && results.name[rows.from] !== '') {
|
||||
rowName = _.str.sprintf(' (%s)', results.name[rows.from]);
|
||||
}
|
||||
if (from === to) {
|
||||
return _.str.sprintf(_t("at row %d%s"), from, rowName);
|
||||
}
|
||||
return _.str.sprintf(_t("between rows %d and %d"),
|
||||
from, to);
|
||||
},
|
||||
at_multi: function (rows) {
|
||||
var from = rows.from + offset;
|
||||
var to = rows.to + offset;
|
||||
var rowName = '';
|
||||
if (results.name.length > rows.from && results.name[rows.from] !== '') {
|
||||
rowName = _.str.sprintf(' (%s)', results.name[rows.from]);
|
||||
}
|
||||
if (from === to) {
|
||||
return _.str.sprintf(_t("Row %d%s"), from, rowName);
|
||||
}
|
||||
return _.str.sprintf(_t("Between rows %d and %d"),
|
||||
from, to);
|
||||
},
|
||||
at_multi_header: function (numberLines) {
|
||||
return _.str.sprintf(_t("at %d different rows:"),
|
||||
numberLines);
|
||||
},
|
||||
more: function (n) {
|
||||
return _.str.sprintf(_t("(%d more)"), n);
|
||||
},
|
||||
info: function (msg) {
|
||||
if (typeof msg === 'string') {
|
||||
return _.str.sprintf(
|
||||
'<div class="oe_import_moreinfo oe_import_moreinfo_message">%s</div>',
|
||||
_.str.escapeHTML(msg));
|
||||
}
|
||||
if (msg instanceof Array) {
|
||||
return _.str.sprintf(
|
||||
'<div class="oe_import_moreinfo oe_import_moreinfo_choices"><a href="#" class="oe_import_report_see_possible_value oe_import_see_all"><i class="fa fa-arrow-right"/> %s </a><ul class="oe_import_report_more">%s</ul></div>',
|
||||
_.str.escapeHTML(_t("See possible values")),
|
||||
_(msg).map(function (msg) {
|
||||
return '<li>'
|
||||
+ _.str.escapeHTML(msg)
|
||||
+ '</li>';
|
||||
}).join(''));
|
||||
}
|
||||
// Final should be object, action descriptor
|
||||
return [
|
||||
'<div class="oe_import_moreinfo oe_import_moreinfo_action">',
|
||||
_.str.sprintf('<a href="#" data-action="%s" class="oe_import_see_all"><i class="fa fa-arrow-right"/> ',
|
||||
_.str.escapeHTML(JSON.stringify(msg))),
|
||||
_.str.escapeHTML(
|
||||
_t("See possible values")),
|
||||
'</a>',
|
||||
'</div>'
|
||||
].join('');
|
||||
},
|
||||
}));
|
||||
// regroup errors by field
|
||||
var errorsByFields = this._regroupErrorsByFields(errorsSorted);
|
||||
|
||||
// If no general comments (index "0"), add general warning that there are some errors/warnings
|
||||
if (!errorsByFields[0] && error_type) {
|
||||
var message = error_type === "warning" ?
|
||||
_t("The file contains non-blocking warnings (see below)") :
|
||||
_t("The file contains blocking errors (see below)");
|
||||
errorsByFields[0] = [{
|
||||
'type': error_type,
|
||||
'message': message,
|
||||
}];
|
||||
}
|
||||
|
||||
// Clean old comments
|
||||
self._cleanComments();
|
||||
|
||||
// Add new comments
|
||||
_.each(errorsByFields, function (errors, field) {
|
||||
var $errorCell = self.$('.oe_import_comment_cell[field="'+ field +'"]');
|
||||
var $errorDiv = $errorCell.find(".oe_import_comments_div");
|
||||
var errorTemplate = 'ImportView.fieldError';
|
||||
// If error not linked to a targeted Odoo field, show in global error div
|
||||
if ($errorDiv.length === 0) {
|
||||
$errorDiv = self.$('.oe_import_error_report');
|
||||
self.$form.addClass('oe_import_error');
|
||||
field = 0;
|
||||
errorTemplate = 'ImportView.error';
|
||||
}
|
||||
$errorDiv.append(
|
||||
QWeb.render(errorTemplate, {
|
||||
errors: errors,
|
||||
field: field,
|
||||
result_names: results.name,
|
||||
offset: offset,
|
||||
})
|
||||
);
|
||||
var mainError = errors[0];
|
||||
if (!mainError.not_matching_error && mainError.type === "error") {
|
||||
$errorCell.find(".oe_import_options_div").removeClass('d-none');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* This method regroups the errors by their 'field' key.
|
||||
* Errors of same nature encountered on multiple rows for the same field are regrouped into the same unique error
|
||||
* and rows_from and rows_to are adapted accordingly.
|
||||
* The goal is to ease the error attribution by looping through each mapped field and add their corresponding errors
|
||||
* to the comment cell of that mapped field.
|
||||
* Some errors are not linked to a mapped field and will be displayed in the general error container.
|
||||
* Those general errors will be identified with key "0".
|
||||
*
|
||||
* @private
|
||||
* @param {Array} errorsSorted: list of errors sorted by error gravity, then by field.
|
||||
* Each item of the list is a list of error of the same nature and can be therefore composed of 1 or more errors.
|
||||
* E.g.: [
|
||||
* [
|
||||
* {
|
||||
* 'type': 'error',
|
||||
* 'field_path': 'product_id/price',
|
||||
* 'rows': {'from': 1, 'to': 1}, // This error have been encountered between row 1 and row 1 (at row 1)
|
||||
* 'message': {"Missing Value"}
|
||||
* },
|
||||
* {
|
||||
* 'type': 'error',
|
||||
* 'field_path': 'product_id/price',
|
||||
* 'rows': {'from': 2, 'to': 2}, // This error have been encountered between row 2 and row 2 (at row 2)
|
||||
* 'message': {"Missing Value"}
|
||||
* }
|
||||
* ],
|
||||
* [ // this one is a general error, not linked specifically to a mapped field.
|
||||
* {
|
||||
* 'type': 'error',
|
||||
* 'message': {"Found more than 10 errors and more than one error per 10 records, interrupted to avoid showing too many errors."}
|
||||
* }
|
||||
* ],
|
||||
* [
|
||||
* {
|
||||
* 'type': 'warning',
|
||||
* 'field': 'quantity',
|
||||
* 'rows': {'from': 2, 'to': 4}, // This error have been encountered between row 2 and row 4 (at 3 different rows)
|
||||
* 'message': {}
|
||||
* }
|
||||
* ],
|
||||
* ]
|
||||
* @returns {Dict} errorsByFields: {fieldName: [error1, error2, ...]}
|
||||
* E.g.: {
|
||||
* 0: [
|
||||
* {
|
||||
* 'type': 'error',
|
||||
* 'message': {"Found more than 10 errors and more than one error per 10 records, interrupted to avoid showing too many errors."}
|
||||
* }
|
||||
* ],
|
||||
* 'product_id/price': [
|
||||
* {
|
||||
* 'type': 'error',
|
||||
* 'field_path': 'product_id/price',
|
||||
* 'rows': {'from': 1, 'to': 2}, // This error have been encountered between row 1 and row 2
|
||||
* 'message': {"Missing Value"}
|
||||
* }
|
||||
* ],
|
||||
* 'quantity': [
|
||||
* {
|
||||
* 'type': 'warning',
|
||||
* 'field': 'quantity',
|
||||
* 'rows': {'from': 2, 'to': 4}, // This error have been encountered between row 2 and row 4
|
||||
* 'message': {}
|
||||
* }
|
||||
* ],
|
||||
* }
|
||||
*/
|
||||
_regroupErrorsByFields: function (errorsSorted) {
|
||||
var errorsByFields = {}
|
||||
_.each(errorsSorted, function (errors) {
|
||||
var mainError = errors[0];
|
||||
var field = mainError.field_path ? mainError.field_path.join('/') : mainError.field || 0;
|
||||
|
||||
// regroup errors for same value at different rows in the same error.
|
||||
if (errors.length > 1 && mainError.rows) {
|
||||
var rowFrom = mainError.rows.from;
|
||||
var rowTo = errors[errors.length - 1].rows.to;
|
||||
errors = mainError;
|
||||
mainError.rows.from = rowFrom;
|
||||
mainError.rows.to = rowTo;
|
||||
} else {
|
||||
errors = mainError;
|
||||
}
|
||||
|
||||
if (!errorsByFields[field]) {
|
||||
errorsByFields[field] = [errors];
|
||||
} else {
|
||||
errorsByFields[field].push(errors);
|
||||
}
|
||||
});
|
||||
|
||||
return errorsByFields;
|
||||
},
|
||||
|
||||
toggle_partial: function (result) {
|
||||
var $form = this.$('.oe_import');
|
||||
var $partial_warning = this.$('.o_import_partial_alert');
|
||||
|
||||
+118
-42
@@ -1,6 +1,6 @@
|
||||
.oe_import {
|
||||
@include o-webclient-padding($top: 8px);
|
||||
overflow: auto;
|
||||
padding: 0px;
|
||||
overflow: hidden;
|
||||
position: absolute; // Needed for chrome
|
||||
top: 0;
|
||||
right: 0;
|
||||
@@ -22,10 +22,10 @@
|
||||
}
|
||||
|
||||
.oe_import_box {
|
||||
padding: 8px;
|
||||
background: #F0EEEE;
|
||||
flex: 0 0 250px;
|
||||
padding: 10px 14px;
|
||||
border-radius: $border-radius;
|
||||
border: solid 1px #dddddd;
|
||||
border-right: solid 1px #dddddd;
|
||||
label {
|
||||
font-weight: normal;
|
||||
}
|
||||
@@ -34,26 +34,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
a.oe_import_toggle {
|
||||
display: block;
|
||||
&:before {
|
||||
content: '+'
|
||||
}
|
||||
}
|
||||
.oe_import_options {
|
||||
margin-top: 8px;
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
label {
|
||||
width: 48%;
|
||||
line-height: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
/* ----------- INITIAL SETUP ------------ */
|
||||
dd,
|
||||
.oe_import_toggled,
|
||||
.oe_import_grid,
|
||||
.oe_import_error_report,
|
||||
.oe_import_noheaders,
|
||||
@@ -61,20 +43,30 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.oe_import_with_file label {
|
||||
font-weight: normal;
|
||||
}
|
||||
.oe_import_debug_options {
|
||||
max-width: 800px;
|
||||
columns: 1;
|
||||
@include media-breakpoint-up(md) {
|
||||
columns: 2;
|
||||
.oe_import_with_file {
|
||||
flex: 1 1 100%;
|
||||
background-color: white;
|
||||
overflow: auto;
|
||||
label {
|
||||
font-weight: normal;
|
||||
&.custom-control-label {
|
||||
left: 20px;
|
||||
}
|
||||
}
|
||||
// try to keep the batch fields together, doesn't work on firefox &
|
||||
// not sure how to do that (except by adding intermediate dom
|
||||
// elements)
|
||||
.oe_import_batch_limit {
|
||||
break-before: column;
|
||||
h4 {
|
||||
margin: 10px 0px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
input:checked + label:after {
|
||||
background-image: url('/web_studio/static/src/img/ui/checkbox_active.svg');
|
||||
background-size: cover;
|
||||
}
|
||||
.truncate {
|
||||
max-width: 178px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +79,39 @@
|
||||
}
|
||||
.oe_import_report_showmore .oe_import_report_more {
|
||||
display: list-item;
|
||||
list-style-type: none;
|
||||
li {
|
||||
list-style-type: disc;
|
||||
}
|
||||
}
|
||||
.oe_import_report_count, .oe_import_report_count_less {
|
||||
.arrow {
|
||||
border: solid black;
|
||||
border-width: 0 2px 2px 0;
|
||||
display: inline-block;
|
||||
padding: 2px;
|
||||
margin-right: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.down {
|
||||
transform: rotate(45deg);
|
||||
-webkit-transform: rotate(45deg);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.up {
|
||||
transform: rotate(-135deg);
|
||||
-webkit-transform: rotate(-135deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------- ERRORS AND WARNINGS REPORT ------------ */
|
||||
.oe_import_error_report > ul {
|
||||
padding: 0;
|
||||
.oe_import_error_report {
|
||||
border-bottom: 1px solid lightgrey;
|
||||
> ul {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.oe_import_report {
|
||||
list-style: none;
|
||||
@@ -117,16 +137,20 @@
|
||||
/* ------------- THE CSV TABLE ------------ */
|
||||
|
||||
$cell-max-width: 350px;
|
||||
$cell-padding: 4px;
|
||||
$cell-padding: 8px;
|
||||
.oe_import_grid {
|
||||
tr {
|
||||
border-bottom: 1px solid lightgrey;
|
||||
&.oe_import_grid-header:first-child {
|
||||
line-height: 24px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.oe_import_grid-cell {
|
||||
cursor:default;
|
||||
border-left: 0px;
|
||||
border-right: 0px;
|
||||
max-width: $cell-max-width;
|
||||
padding: $cell-padding;
|
||||
padding: $cell-padding 12px;
|
||||
vertical-align: top;
|
||||
.o_multi_line_text {
|
||||
word-break: break-word;
|
||||
@@ -134,13 +158,60 @@
|
||||
.o_single_line_text {
|
||||
@include o-text-overflow($display: table-cell, $max-width: $cell-max-width - $cell-padding);
|
||||
}
|
||||
.alert {
|
||||
// alias -error to -danger
|
||||
&.alert-error {
|
||||
background-color: transparent;
|
||||
}
|
||||
&.alert-warning {
|
||||
background-color: transparent;
|
||||
}
|
||||
&.alert-info {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Default Color for placeholder on import fields*/
|
||||
.select2-default{
|
||||
color: #F00 !important;
|
||||
color: #F0AD4E !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Common dropdown style */
|
||||
.oe_import_dropdown {
|
||||
width: 100% !important;
|
||||
|
||||
select {
|
||||
border-radius: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
select, .select2-choice {
|
||||
border: 1px solid #aaa;
|
||||
height: 26px;
|
||||
padding-right: 24px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
background-image: url('/base_import/static/src/img/ui/dropdown_arrow_small.png');
|
||||
background-position: center right;
|
||||
background-repeat: no-repeat;
|
||||
appearance: none;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.select2-arrow {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
input.oe_import_dropdown {
|
||||
border-radius: 4px;
|
||||
border: 1px solid darkgrey;
|
||||
height: 26px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
}
|
||||
/* ------------- PARTIAL MODE buttons ------------ */
|
||||
@@ -156,5 +227,10 @@
|
||||
ul, li {
|
||||
margin: 0; padding: 0;
|
||||
}
|
||||
width: 250px !important;
|
||||
.select2-results {
|
||||
max-height: 192px;
|
||||
.text-decoration-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
@mixin o-import-sprite-icon($x: 0, $y: 0) {
|
||||
background-position: $x $y;
|
||||
}
|
||||
|
||||
/*
|
||||
* Override select style
|
||||
*/
|
||||
|
||||
/* Field Type Dropdown */
|
||||
.oe_import_dropdown.oe_import_match_field {
|
||||
.o_import_field_icon {
|
||||
padding-left: 34px;
|
||||
|
||||
abbr {
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
/* Show icon */
|
||||
&:before {
|
||||
width: 30px;
|
||||
height: 26px;
|
||||
display: block;
|
||||
float: left;
|
||||
margin: 0px 5px 0 0;
|
||||
border-right: 1px solid #a0abb9;
|
||||
background-color: white;
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: -1px;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-top-left-radius: 4px;
|
||||
border: 1px solid darkgrey;
|
||||
}
|
||||
&:after{
|
||||
background-image: url('/base_import/static/src/img/ui/studio_icons.png');
|
||||
background-repeat: no-repeat;
|
||||
content: '';
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 4px;
|
||||
bottom: auto;
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_import_field_binary:after {
|
||||
@include o-import-sprite-icon(0, 0);
|
||||
}
|
||||
|
||||
.o_import_field_boolean:after {
|
||||
@include o-import-sprite-icon(0, -20px);
|
||||
}
|
||||
|
||||
.o_import_field_char:after {
|
||||
@include o-import-sprite-icon(0, -40px);
|
||||
}
|
||||
|
||||
.o_import_field_date:after {
|
||||
@include o-import-sprite-icon(0, -80px);
|
||||
}
|
||||
|
||||
.o_import_field_datetime:after {
|
||||
@include o-import-sprite-icon(0, -100px);
|
||||
}
|
||||
|
||||
.o_import_field_float:after {
|
||||
@include o-import-sprite-icon(0, -120px);
|
||||
}
|
||||
|
||||
.o_import_field_html:after {
|
||||
@include o-import-sprite-icon(0, -140px);
|
||||
}
|
||||
|
||||
.o_import_field_integer:after {
|
||||
@include o-import-sprite-icon(0, -180px);
|
||||
}
|
||||
|
||||
.o_import_field_many2many:after {
|
||||
@include o-import-sprite-icon(0, -200px);
|
||||
}
|
||||
|
||||
.o_import_field_many2one:after {
|
||||
@include o-import-sprite-icon(0, -220px);
|
||||
}
|
||||
|
||||
.o_import_field_one2many:after {
|
||||
@include o-import-sprite-icon(0, -260px);
|
||||
}
|
||||
|
||||
.o_import_field_selection:after {
|
||||
@include o-import-sprite-icon(0, -280px);
|
||||
}
|
||||
|
||||
.o_import_field_text:after {
|
||||
@include o-import-sprite-icon(0, -340px);
|
||||
}
|
||||
@@ -12,101 +12,25 @@
|
||||
<form action="" method="post" enctype="multipart/form-data" class="oe_import">
|
||||
<input type="hidden" name="csrf_token" t-att-value="csrf_token"/>
|
||||
<input type="hidden" name="import_id"/>
|
||||
<div class="oe_import_box d-none">
|
||||
<input accept=".csv, .xls, .xlsx, .xlsm, .ods" t-attf-id="file_#{_id}"
|
||||
name="file" class="oe_import_file" type="file" style="display:none;"/>
|
||||
<div class="oe_import_with_file row">
|
||||
<a href="#" class="oe_import_toggle col-sm-12">
|
||||
Formatting Options…</a>
|
||||
<div class="row col-sm-12">
|
||||
<div class="oe_import_toggled oe_import_options js_import_options col-md-6 col-lg-4">
|
||||
<p t-foreach="widget.opts" t-as="option">
|
||||
<!-- no @name, avoid submission when file_update called -->
|
||||
<label t-attf-for="#{option.name}_#{_id}">
|
||||
<t t-esc="option.label"/></label>
|
||||
<input t-attf-id="#{option.name}_#{_id}"
|
||||
t-attf-class="oe_import_#{option.name}"
|
||||
style="width: 50%;"
|
||||
t-att-value="option.value"/>
|
||||
</p>
|
||||
</div>
|
||||
<div t-foreach="[widget.parse_opts_formats, widget.parse_opts_separators]" t-as="options" class="oe_import_toggled oe_import_options col-md-6 col-lg-4">
|
||||
<p t-foreach="options" t-as="option">
|
||||
<!-- no @name, avoid submission when file_update called -->
|
||||
<label t-attf-for="#{option.name}_#{_id}">
|
||||
<t t-esc="option.label"/></label>
|
||||
<input t-attf-id="#{option.name}_#{_id}"
|
||||
t-attf-class="oe_import_#{option.name}"
|
||||
style="width: 50%;"
|
||||
t-att-value="option.value"/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_import_batch_alert alert alert-warning d-none">
|
||||
Due to its large size, the file will be imported by batches.
|
||||
|
||||
<div class="d-flex h-100">
|
||||
<t t-call="ImportView.side_panel"/>
|
||||
<t t-call="ImportView.data_matching"/>
|
||||
</div>
|
||||
|
||||
<div class="o_import_partial_alert alert alert-warning d-none">
|
||||
Click 'Resume' to proceed with the import, resuming at line
|
||||
<span class="o_import_partial_count">0</span>.<br/>
|
||||
You can test or reload your file before resuming the import.
|
||||
</div>
|
||||
|
||||
<div class="oe_import_with_file d-none">
|
||||
<h2>Map your columns to import</h2>
|
||||
<div class="oe_import_debug_options">
|
||||
<div title="If the model uses openchatter, history tracking will set up subscriptions and send notifications during the import, but lead to a slower import." class="oe_import_debug_option">
|
||||
<input type="checkbox" id="oe_import_tracking"/>
|
||||
<label for="oe_import_tracking">
|
||||
Track history during import
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="oe_import_has_multiple_sheets js_import_options">
|
||||
<label for="oe_import_sheet">Selected Sheet:</label>
|
||||
<input class="oe_import_sheet" id="oe_import_sheet"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" class="oe_import_has_header"
|
||||
id="oe_import_has_header" checked="checked"/>
|
||||
<label for="oe_import_has_header">The first row
|
||||
contains the label of the column</label>
|
||||
</div>
|
||||
<div class="js_import_options oe_import_debug_option oe_import_batch_limit">
|
||||
<label for="oe_import_batch_limit">Batch limit</label>
|
||||
<input id="oe_import_batch_limit" value="2000"/>
|
||||
</div>
|
||||
<div class="js_import_options oe_import_debug_option" title="Warning: ignores the labels line, empty lines and
|
||||
lines composed only of empty cells">
|
||||
<label for="oe_import_row_start">Start at line</label>
|
||||
<input id="oe_import_row_start" value="1"/>
|
||||
</div>
|
||||
<div class="oe_import_debug_option">
|
||||
<input type="checkbox" class="oe_import_advanced_mode" checked="checked"
|
||||
id="oe_import_advanced_mode"/>
|
||||
<label for="oe_import_advanced_mode">Show fields of relation fields (advanced)</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="oe_import_noheaders text-muted">If the file contains
|
||||
the column names, Odoo can try auto-detecting the
|
||||
field corresponding to the column. This makes imports
|
||||
simpler especially when the file has many columns.</p>
|
||||
|
||||
<div class="oe_import_error_report"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table-striped table-bordered oe_import_grid bg-white" />
|
||||
</div>
|
||||
<h6 class="oe_padding">This is a preview of the first 10 rows of your file</h6>
|
||||
</div>
|
||||
<div class="o_view_nocontent">
|
||||
<div class="o_nocontent_help">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Select a CSV or Excel file to import.
|
||||
Upload an Excel or CSV file to import
|
||||
</p>
|
||||
<p>
|
||||
Excel files are recommended as fields formatting is automatic.
|
||||
Excel files are recommended as formatting is automatic.
|
||||
</p>
|
||||
<div class="mt16 mb4">Need Help?</div>
|
||||
<div t-foreach="widget.importTemplates" t-as="template">
|
||||
@@ -124,47 +48,73 @@
|
||||
<button type="button" class="btn btn-primary o_import_import o_import_import_full d-none">Import</button>
|
||||
<button type="button" class="btn btn-primary o_import_import o_import_import_partial d-none">Resume</button>
|
||||
<button type="button" class="btn btn-secondary o_import_validate d-none">Test</button>
|
||||
<button type="button" class="btn btn-primary oe_import_file">Load File</button>
|
||||
<button type="button" class="btn btn-primary oe_import_file">Upload File</button>
|
||||
<button type="button" class="btn btn-secondary o_import_cancel">Cancel</button>
|
||||
</t>
|
||||
|
||||
<t t-name="ImportView.create_record_option">
|
||||
<div class="mt4">
|
||||
<label title="Creates new records if they can't be found (instead of failing to import). Note that the value in the column will be used as the new record's 'name', and assumes this is sufficient to create the record.">
|
||||
<input type="checkbox" class="o_import_create_option"/>
|
||||
Create if doesn't exist
|
||||
</label>
|
||||
<div t-attf-class="oe_import_dropdown o_import_field_#{data.type} position-relative alert alert-error">
|
||||
<b>When a value cannot be matched:</b>
|
||||
<select class="o_import_create_option" t-att-type="data.type" t-att-field="data.id"
|
||||
t-att-model="data.comodel_name || data.model_name">
|
||||
<option value="prevent">Prevent import</option>
|
||||
<t t-if="data.type === 'boolean'">
|
||||
<option value="false">Set to: False</option>
|
||||
<option value="true" >Set to: True</option>
|
||||
</t>
|
||||
<option t-if="show_skip" value="skip">Skip unknown values</option>
|
||||
<option t-if="is_relational" value="create">Create new values</option>
|
||||
<t t-if="data.type === 'selection'">
|
||||
<option t-foreach="values" t-as="value" t-att-value="value">Set to: <t t-esc="value"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="ImportView.preview">
|
||||
<thead>
|
||||
<tr t-if="headers" class="oe_import_grid-header">
|
||||
<td t-foreach="headers" t-as="header" class="oe_import_grid-cell"
|
||||
><span class="o_import_header_name o_single_line_text" t-att-title="header"><t t-esc="header"/></span></td>
|
||||
</tr>
|
||||
<tr class="oe_import_fields">
|
||||
<!-- Iterate on first row to ensure we have all columns -->
|
||||
<td t-foreach="preview[0]" t-as="column">
|
||||
<input class="oe_import_match_field"/>
|
||||
</td>
|
||||
<tr class="oe_import_grid-header">
|
||||
<td class="oe_import_grid-cell border-0 font-weight-bold" style="width: 20%;">
|
||||
<span class="o_single_line_text" title="File Column">File Column</span></td>
|
||||
<td class="oe_import_grid-cell border-0 font-weight-bold" style="width: 15%;" >
|
||||
<span class="o_single_line_text" title="File Column">Odoo Field</span></td>
|
||||
<td class="oe_import_grid-cell border-0 font-weight-bold">
|
||||
<span class="o_single_line_text" title="File Column">Comments</span></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="preview" t-as="row" class="oe_import_grid-row">
|
||||
<td t-foreach="row" t-as="cell" class="oe_import_grid-cell">
|
||||
<!-- content can be displayed on several lines if it contains whitespaces -->
|
||||
<!-- in that case, we only display the 120 first characters -->
|
||||
<!-- otherwise, we let the text-overflow: ellipsis do the job -->
|
||||
<t t-set="multiline" t-value="cell.includes(' ')"/>
|
||||
<t t-set="content" t-value="(multiline && cell.length > 120) ? (cell.substring(0, 120) + '...') : cell"/>
|
||||
<span t-attf-class="#{multiline ? 'o_multi_line_text' : 'o_single_line_text'}">
|
||||
<t t-esc="content"/>
|
||||
<t t-set="columns" t-value="headers || preview"/>
|
||||
<tr t-if="columns" t-foreach="columns" t-as="column" class="oe_import_grid-row">
|
||||
<td class="oe_import_grid-cell oe_import_name_cell">
|
||||
<span class="o_import_header_name o_single_line_text" t-att-title="column">
|
||||
<span t-if="column" t-esc="column"/>
|
||||
<span t-else="">Untitled</span>
|
||||
</span>
|
||||
<span t-if="options['has_headers']" class="font-italic text-muted small"><t t-esc="preview[column_index]"/></span>
|
||||
</td>
|
||||
<td class="oe_import_grid-cell oe_import_match_cell">
|
||||
<input class="oe_import_match_field oe_import_dropdown"/>
|
||||
</td>
|
||||
<td class="oe_import_grid-cell oe_import_comment_cell">
|
||||
<div class="oe_import_comments_div"/>
|
||||
<div class="oe_import_options_div d-none"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</t>
|
||||
|
||||
<t t-name="ImportView.comment_same_mapped_field">
|
||||
<div class="oe_import_same_mapped_field alert alert-info">
|
||||
This column will be concatenated in field <b t-esc="field"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="ImportView.comment_m2m_field">
|
||||
<div class="oe_import_m2m_field alert alert-info">
|
||||
To import multiple values, separate them by a comma
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="ImportView.preview.error">
|
||||
<div class="oe_import_report alert alert-danger">
|
||||
<p>Import preview failed due to: <t t-esc="error"/>.</p>
|
||||
@@ -173,55 +123,196 @@
|
||||
</div>
|
||||
<pre t-if="preview"><t t-esc="preview"/></pre>
|
||||
</t>
|
||||
<ul t-name="ImportView.error">
|
||||
<li t-foreach="errors" t-as="error"
|
||||
t-attf-class="oe_import_report alert alert-#{error_value[0].type}">
|
||||
<t t-if="error_value.length gt 1">
|
||||
<t t-call="ImportView.error.multi.header">
|
||||
<t t-set="error" t-value="error_value[0]"/>
|
||||
<t t-set="error_length" t-value="error_value.length"/>
|
||||
</t>
|
||||
<ul>
|
||||
<t t-foreach="error_value.length" t-as="index">
|
||||
<li t-att-class="index gt 4 ? 'oe_import_report_more':''">
|
||||
<t t-call="ImportView.error.multi.body">
|
||||
<t t-set="error" t-value="error_value[index]"/>
|
||||
<t t-set="index" t-value="index"/>
|
||||
</t>
|
||||
</li>
|
||||
<li t-if="error_value.length gt 5 and index == 4" style="display: block;">
|
||||
<a href="#" class="oe_import_report_count">
|
||||
<t t-esc="more(error_value.length - 5)"/>
|
||||
</a>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
<t t-if="error_value[0].moreinfo" t-raw="info(error_value[0].moreinfo)"/>
|
||||
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-call="ImportView.error.single">
|
||||
<t t-set="error" t-value="error_value[0]"/>
|
||||
<t t-name="ImportView.error">
|
||||
<div t-foreach="errors" t-as="error" t-attf-class="oe_import_report alert alert-#{error.type}">
|
||||
<span class="oe_import_report_message">
|
||||
<b><t t-esc="error.message"/></b>
|
||||
<t t-if="error.rows" t-call="ImportView.error.at">
|
||||
<t t-set="rows" t-value="error.rows"/>
|
||||
</t>
|
||||
</span>
|
||||
<t t-if="error.moreinfo" t-call="ImportView.error.more_info">
|
||||
<t t-set="messages" t-value="error.moreinfo"/>
|
||||
</t>
|
||||
</li>
|
||||
</ul>
|
||||
<t t-name="ImportView.error.multi.header">
|
||||
<span class="oe_import_report_message">
|
||||
<t t-esc="error.message"/>
|
||||
<t t-esc="at_multi_header(error_length)"/>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="ImportView.error.multi.body">
|
||||
|
||||
<div t-name="ImportView.fieldError" t-attf-class="oe_import_report alert alert-#{errors[0].type}">
|
||||
<t t-if="errors.length > 1" t-call="ImportView.fieldError.multi"></t>
|
||||
<t t-else="" t-call="ImportView.fieldError.single">
|
||||
<t t-set="error" t-value="errors[0]"/>
|
||||
</t>
|
||||
</div>
|
||||
<t t-name="ImportView.fieldError.single">
|
||||
<span class="oe_import_report_message">
|
||||
<t t-esc="at_multi(error.rows)"/>
|
||||
<t t-if="error.value && !error.error_message">
|
||||
No matching records found for <t t-esc="error.field_type"/> <b>'<t t-esc="error.value"/>'</b> in field <b t-esc="error.field_name"/>
|
||||
</t>
|
||||
<b t-else="" t-esc="error.message"/>
|
||||
</span>
|
||||
<t t-if="error.rows" t-call="ImportView.error.at">
|
||||
<t t-set="rows" t-value="error.rows"/>
|
||||
</t>
|
||||
<t t-if="error.moreinfo" t-call="ImportView.error.more_info">
|
||||
<t t-set="messages" t-value="error.moreinfo"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-name="ImportView.error.single">
|
||||
<span class="oe_import_report_message">
|
||||
<t t-esc="error.message"/>
|
||||
</span>
|
||||
<t t-if="error.rows" t-esc="at(error.rows)"/>
|
||||
<t t-if="error.moreinfo" t-raw="info(error.moreinfo)"/>
|
||||
|
||||
<t t-name="ImportView.fieldError.multi">
|
||||
<div class="oe_import_report_message">
|
||||
<t t-if="errors[0].value">
|
||||
No matching records found for the following <t t-esc="errors[0].field_type"/> </t>
|
||||
<t t-else="">Multiple errors occured </t>
|
||||
in field <b t-esc="errors[0].field_name"/>:
|
||||
<ul>
|
||||
<t t-foreach="errors.length" t-as="index">
|
||||
<li t-att-class="index > 2 ? 'oe_import_report_more':''">
|
||||
<b t-if="errors[index].error_message" t-esc="errors[index].message"/>
|
||||
<b t-else="" t-esc="errors[index].value || errors[index].message"/>
|
||||
<t t-if="errors[index].rows" t-call="ImportView.error.at">
|
||||
<t t-set="rows" t-value="errors[index].rows"/>
|
||||
</t>
|
||||
</li>
|
||||
<li t-if="errors.length > 3 and index == 2" style="display: block;">
|
||||
<a href="#" class="oe_import_report_count">
|
||||
( <i class="arrow down"></i> <t t-esc="errors.length - 3"/> more)
|
||||
</a>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<t t-if="errors[0].moreinfo" t-call="ImportView.error.more_info">
|
||||
<t t-set="messages" t-value="errors[0].moreinfo"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="ImportView.error.at">
|
||||
<t t-set="from" t-value="rows.from + offset"/>
|
||||
<t t-set="to" t-value="rows.to + offset"/>
|
||||
|
||||
<t t-if="from === to">
|
||||
at row <t t-esc="from"/> <t t-if="result_names.length > rows.from && result_names[rows.from] !== ''">(<t t-esc="result_names[rows.from]"/>)</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
at multiple rows
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="ImportView.error.more_info">
|
||||
<div t-if="typeof messages === 'string'" class="oe_import_moreinfo oe_import_moreinfo_action">
|
||||
<t t-esc="messages"/>
|
||||
</div>
|
||||
<div t-elif="messages instanceof Array" class="oe_import_moreinfo oe_import_moreinfo_choices">
|
||||
<a href="#" class="oe_import_report_see_possible_value oe_import_see_all">
|
||||
<i class="fa fa-arrow-right"/> See possible values </a>
|
||||
<ul class="oe_import_report_more">
|
||||
<li t-foreach="messages" t-as="msg"><t t-esc="msg"/></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div t-else="" class="oe_import_moreinfo oe_import_moreinfo_action">
|
||||
<a href="#" t-att-data-action="JSON.stringify(messages)" class="oe_import_see_all">
|
||||
<i class="fa fa-arrow-right"/> See possible values </a>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="ImportView.data_matching">
|
||||
<div class="oe_import_with_file d-none">
|
||||
<div class="oe_import_noheaders">
|
||||
<p class="alert alert-info">If the file contains
|
||||
the column names, Odoo can try auto-detecting the
|
||||
field corresponding to the column. This makes imports
|
||||
simpler especially when the file has many columns.</p>
|
||||
</div>
|
||||
<div class="oe_import_error_report px-3 py-2"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table-striped oe_import_grid w-100 overflow-hidden" />
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="ImportView.side_panel">
|
||||
<div class="oe_import_box d-none bg-white overflow-auto">
|
||||
<input accept=".csv, .xls, .xlsx, .xlsm, .ods" t-attf-id="file_#{_id}"
|
||||
name="file" class="oe_import_file" type="file" style="display:none;"/>
|
||||
<div class="oe_import_with_file">
|
||||
<h4>Imported file</h4>
|
||||
<div class="mb-2 d-flex align-items-center">
|
||||
<i class="fa fa-file white mr-2"></i>
|
||||
<div id="oe_imported_file" class="font-italic truncate"></div>
|
||||
<span id="oe_imported_file_extension" class="font-italic"></span>
|
||||
</div>
|
||||
|
||||
<div class="oe_import_has_multiple_sheets js_import_options flex-column mt-2">
|
||||
<label for="oe_import_sheet">Sheet:</label>
|
||||
<input class="oe_import_sheet oe_import_dropdown" id="oe_import_sheet"/>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" class="oe_import_has_header custom-control-input"
|
||||
id="oe_import_has_header" checked="checked"/>
|
||||
<label class="custom-control-label" for="oe_import_has_header">Use first row as header</label>
|
||||
|
||||
<div class="o_import_formatting">
|
||||
<h4 class="mt-3">Formatting</h4>
|
||||
<div t-foreach="[widget.opts, widget.parse_opts_formats, widget.parse_opts_separators]" t-as="options" class="oe_import_options">
|
||||
<div t-attf-class="mb-1 oe_import_#{option.type}_box" t-foreach="options" t-as="option">
|
||||
<!-- no @name, avoid submission when file_update called -->
|
||||
<label t-attf-for="#{option.name}_#{_id}">
|
||||
<t t-esc="option.label"/></label>
|
||||
<input
|
||||
t-attf-id="#{option.name}_#{_id}"
|
||||
t-attf-class="oe_import_#{option.name} oe_import_dropdown w-100 mb-3"
|
||||
t-att-value="option.value"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_import_batch">
|
||||
<h4 class="mt-3">Batch Import</h4>
|
||||
<div class="o_import_batch_alert alert alert-info d-none">
|
||||
The file will be imported by batches.
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="js_import_options oe_import_options oe_import_batch_limit w-50 pr-1">
|
||||
<label class="mb-1" for="oe_import_batch_limit">Batch limit</label>
|
||||
<input class="w-100" id="oe_import_batch_limit" value="2000"/>
|
||||
</div>
|
||||
<div class="js_import_options oe_import_options w-50 pl-1" title="Warning: ignores the labels line, empty lines and
|
||||
lines composed only of empty cells">
|
||||
<label class="mb-1" for="oe_import_row_start">Start at line</label>
|
||||
<input class="w-100" id="oe_import_row_start" value="1"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-3">Help</h4>
|
||||
<div t-foreach="widget.importTemplates" t-as="template">
|
||||
<a t-att-href="template.template" aria-label="Download" title="Download">
|
||||
<i class="fa fa-download"/> <span>Download Template</span>
|
||||
</a>
|
||||
</div>
|
||||
<a href="https://www.odoo.com/documentation/user/14.0/general/base_import/import_faq.html" target="new">
|
||||
<i class="fa fa-external-link"></i> Go to Import FAQ
|
||||
</a>
|
||||
|
||||
<div class="oe_import_debug_option">
|
||||
<h4 class="mt-3">Advanced</h4>
|
||||
<div title="If the model uses openchatter, history tracking will set up subscriptions and send notifications during the import, but lead to a slower import.">
|
||||
<input type="checkbox" id="oe_import_tracking" class="custom-control-input"/>
|
||||
<label class="custom-control-label" for="oe_import_tracking">
|
||||
Track history during import
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" class="oe_import_advanced_mode custom-control-input" checked="checked"
|
||||
id="oe_import_advanced_mode"/>
|
||||
<label class="custom-control-label" for="oe_import_advanced_mode">Allow matching with subfields</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -9,6 +9,7 @@ import unittest
|
||||
from odoo.tests.common import TransactionCase, can_import
|
||||
from odoo.modules.module import get_module_resource
|
||||
from odoo.tools import mute_logger, pycompat
|
||||
from odoo.addons.base_import.models.base_import import ImportValidationError
|
||||
|
||||
ID_FIELD = {
|
||||
'id': 'id',
|
||||
@@ -21,10 +22,17 @@ ID_FIELD = {
|
||||
}
|
||||
|
||||
|
||||
def make_field(name='value', string='Value', required=False, fields=[], field_type='id'):
|
||||
def make_field(name='value', string='Value', required=False, fields=None, field_type='id', model_name=None, comodel_name=None):
|
||||
if fields is None:
|
||||
fields = []
|
||||
field = {'id': name, 'name': name, 'string': string, 'required': required, 'fields': fields, 'type': field_type}
|
||||
if model_name:
|
||||
field['model_name'] = model_name
|
||||
if comodel_name:
|
||||
field['comodel_name'] = comodel_name
|
||||
return [
|
||||
ID_FIELD,
|
||||
{'id': name, 'name': name, 'string': string, 'required': required, 'fields': fields, 'type': field_type},
|
||||
field,
|
||||
]
|
||||
|
||||
|
||||
@@ -44,18 +52,19 @@ class BaseImportCase(TransactionCase):
|
||||
pprint.pformat(f2).splitlines()
|
||||
))
|
||||
|
||||
|
||||
class TestBasicFields(BaseImportCase):
|
||||
|
||||
def get_fields(self, field):
|
||||
return self.env['base_import.import'].get_fields('base_import.tests.models.' + field)
|
||||
return self.env['base_import.import'].get_fields_tree('base_import.tests.models.' + field)
|
||||
|
||||
def test_base(self):
|
||||
""" A basic field is not required """
|
||||
self.assertEqualFields(self.get_fields('char'), make_field(field_type='char'))
|
||||
self.assertEqualFields(self.get_fields('char'), make_field(field_type='char', model_name='base_import.tests.models.char'))
|
||||
|
||||
def test_required(self):
|
||||
""" Required fields should be flagged (so they can be fill-required) """
|
||||
self.assertEqualFields(self.get_fields('char.required'), make_field(required=True, field_type='char'))
|
||||
self.assertEqualFields(self.get_fields('char.required'), make_field(required=True, field_type='char', model_name='base_import.tests.models.char.required'))
|
||||
|
||||
def test_readonly(self):
|
||||
""" Readonly fields should be filtered out"""
|
||||
@@ -63,7 +72,7 @@ class TestBasicFields(BaseImportCase):
|
||||
|
||||
def test_readonly_states(self):
|
||||
""" Readonly fields with states should not be filtered out"""
|
||||
self.assertEqualFields(self.get_fields('char.states'), make_field(field_type='char'))
|
||||
self.assertEqualFields(self.get_fields('char.states'), make_field(field_type='char', model_name='base_import.tests.models.char.states'))
|
||||
|
||||
def test_readonly_states_noreadonly(self):
|
||||
""" Readonly fields with states having nothing to do with
|
||||
@@ -78,9 +87,11 @@ class TestBasicFields(BaseImportCase):
|
||||
def test_m2o(self):
|
||||
""" M2O fields should allow import of themselves (name_get),
|
||||
their id and their xid"""
|
||||
self.assertEqualFields(self.get_fields('m2o'), make_field(field_type='many2one', fields=[
|
||||
{'id': 'value', 'name': 'id', 'string': 'External ID', 'required': False, 'fields': [], 'type': 'id'},
|
||||
{'id': 'value', 'name': '.id', 'string': 'Database ID', 'required': False, 'fields': [], 'type': 'id'},
|
||||
self.assertEqualFields(self.get_fields('m2o'), make_field(
|
||||
field_type='many2one', comodel_name='base_import.tests.models.m2o.related', model_name='base_import.tests.models.m2o',
|
||||
fields=[
|
||||
{'id': 'value', 'name': 'id', 'string': 'External ID', 'required': False, 'fields': [], 'type': 'id', 'model_name': 'base_import.tests.models.m2o'},
|
||||
{'id': 'value', 'name': '.id', 'string': 'Database ID', 'required': False, 'fields': [], 'type': 'id', 'model_name': 'base_import.tests.models.m2o'},
|
||||
]))
|
||||
|
||||
def test_m2o_required(self):
|
||||
@@ -88,41 +99,43 @@ class TestBasicFields(BaseImportCase):
|
||||
required as well (the client has to handle that: requiredness
|
||||
is id-based)
|
||||
"""
|
||||
self.assertEqualFields(self.get_fields('m2o.required'), make_field(field_type='many2one', required=True, fields=[
|
||||
{'id': 'value', 'name': 'id', 'string': 'External ID', 'required': True, 'fields': [], 'type': 'id'},
|
||||
{'id': 'value', 'name': '.id', 'string': 'Database ID', 'required': True, 'fields': [], 'type': 'id'},
|
||||
self.assertEqualFields(self.get_fields('m2o.required'), make_field(
|
||||
field_type='many2one', required=True, comodel_name='base_import.tests.models.m2o.required.related', model_name='base_import.tests.models.m2o.required',
|
||||
fields=[
|
||||
{'id': 'value', 'name': 'id', 'string': 'External ID', 'required': True, 'fields': [], 'type': 'id', 'model_name': 'base_import.tests.models.m2o.required'},
|
||||
{'id': 'value', 'name': '.id', 'string': 'Database ID', 'required': True, 'fields': [], 'type': 'id', 'model_name': 'base_import.tests.models.m2o.required'},
|
||||
]))
|
||||
|
||||
|
||||
class TestO2M(BaseImportCase):
|
||||
|
||||
def get_fields(self, field):
|
||||
return self.env['base_import.import'].get_fields('base_import.tests.models.' + field)
|
||||
return self.env['base_import.import'].get_fields_tree('base_import.tests.models.' + field)
|
||||
|
||||
def test_shallow(self):
|
||||
self.assertEqualFields(
|
||||
self.get_fields('o2m'), [
|
||||
ID_FIELD,
|
||||
{'id': 'name', 'name': 'name', 'string': "Name", 'required': False, 'fields': [], 'type': 'char',},
|
||||
{'id': 'name', 'name': 'name', 'string': "Name", 'required': False, 'fields': [], 'type': 'char', 'model_name': 'base_import.tests.models.o2m'},
|
||||
{
|
||||
'id': 'value', 'name': 'value', 'string': 'Value',
|
||||
'required': False, 'type': 'one2many',
|
||||
'id': 'value', 'name': 'value', 'string': 'Value', 'model_name': 'base_import.tests.models.o2m',
|
||||
'required': False, 'type': 'one2many', 'comodel_name': 'base_import.tests.models.o2m.child',
|
||||
'fields': [
|
||||
ID_FIELD,
|
||||
{
|
||||
'id': 'parent_id', 'name': 'parent_id',
|
||||
'string': 'Parent', 'type': 'many2one',
|
||||
'id': 'parent_id', 'name': 'parent_id', 'model_name': 'base_import.tests.models.o2m.child',
|
||||
'string': 'Parent', 'type': 'many2one', 'comodel_name': 'base_import.tests.models.o2m',
|
||||
'required': False, 'fields': [
|
||||
{'id': 'parent_id', 'name': 'id',
|
||||
{'id': 'parent_id', 'name': 'id', 'model_name': 'base_import.tests.models.o2m.child',
|
||||
'string': 'External ID', 'required': False,
|
||||
'fields': [], 'type': 'id'},
|
||||
{'id': 'parent_id', 'name': '.id',
|
||||
{'id': 'parent_id', 'name': '.id', 'model_name': 'base_import.tests.models.o2m.child',
|
||||
'string': 'Database ID', 'required': False,
|
||||
'fields': [], 'type': 'id'},
|
||||
]
|
||||
},
|
||||
{'id': 'value', 'name': 'value', 'string': 'Value',
|
||||
'required': False, 'fields': [], 'type': 'integer'
|
||||
'required': False, 'fields': [], 'type': 'integer', 'model_name': 'base_import.tests.models.o2m.child',
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -133,16 +146,28 @@ class TestO2M(BaseImportCase):
|
||||
class TestMatchHeadersSingle(TransactionCase):
|
||||
|
||||
def test_match_by_name(self):
|
||||
match = self.env['base_import.import']._match_header('f0', [{'name': 'f0'}], {})
|
||||
self.assertEqual(match, [{'name': 'f0'}])
|
||||
match = self.env['base_import.import']._get_mapping_suggestion('f0', [{'name': 'f0'}], [], {})
|
||||
self.assertEqual(match, {'field_path': ['f0'], 'distance': 0})
|
||||
|
||||
def test_match_by_string(self):
|
||||
match = self.env['base_import.import']._match_header('some field', [{'name': 'bob', 'string': "Some Field"}], {})
|
||||
self.assertEqual(match, [{'name': 'bob', 'string': "Some Field"}])
|
||||
match = self.env['base_import.import']._get_mapping_suggestion('some field', [{'name': 'bob', 'string': "Some Field"}], [], {})
|
||||
self.assertEqual(match, {'field_path': ['bob'], 'distance': 0})
|
||||
|
||||
def test_nomatch(self):
|
||||
match = self.env['base_import.import']._match_header('should not be', [{'name': 'bob', 'string': "wheee"}], {})
|
||||
self.assertEqual(match, [])
|
||||
match = self.env['base_import.import']._get_mapping_suggestion('should not be', [{'name': 'bob', 'string': "wheee"}], [], {})
|
||||
self.assertEqual(match, {})
|
||||
|
||||
def test_close_match(self):
|
||||
match = self.env['base_import.import']._get_mapping_suggestion('bobe', [{'name': 'bob', 'type': 'char'}], ['char'], {})
|
||||
self.assertEqual(match, {'field_path': ['bob'], 'distance': 0.1428571428571429})
|
||||
|
||||
def test_distant_match(self):
|
||||
Import = self.env['base_import.import']
|
||||
header, field_string = 'same Folding', 'Some Field'
|
||||
match = Import._get_mapping_suggestion(header, [{'name': 'bob', 'string': field_string, 'type': 'char'}], ['char'], {})
|
||||
string_field_dist = Import._get_distance(header.lower(), field_string.lower())
|
||||
self.assertEqual(string_field_dist, 0.36363636363636365)
|
||||
self.assertEqual(match, {}) # if distance >= 0.2, no match returned
|
||||
|
||||
def test_recursive_match(self):
|
||||
f = {
|
||||
@@ -153,8 +178,8 @@ class TestMatchHeadersSingle(TransactionCase):
|
||||
{'name': 'f1', 'string': "Sub field 2", 'fields': []},
|
||||
]
|
||||
}
|
||||
match = self.env['base_import.import']._match_header('f0/f1', [f], {})
|
||||
self.assertEqual(match, [f, f['fields'][1]])
|
||||
match = self.env['base_import.import']._get_mapping_suggestion('f0/f1', [f], [], {})
|
||||
self.assertEqual(match, {'field_path': [f['name'], f['fields'][1]['name']]})
|
||||
|
||||
def test_recursive_nomatch(self):
|
||||
""" Match first level, fail to match second level
|
||||
@@ -167,50 +192,59 @@ class TestMatchHeadersSingle(TransactionCase):
|
||||
{'name': 'f1', 'string': "Sub field 2", 'fields': []},
|
||||
]
|
||||
}
|
||||
match = self.env['base_import.import']._match_header('f0/f2', [f], {})
|
||||
self.assertEqual(match, [])
|
||||
match = self.env['base_import.import']._get_mapping_suggestion('f0/f2', [f], [], {})
|
||||
self.assertEqual(match, {})
|
||||
|
||||
|
||||
class TestMatchHeadersMultiple(TransactionCase):
|
||||
|
||||
def test_noheaders(self):
|
||||
self.assertEqual(
|
||||
self.env['base_import.import']._match_headers([], [], {}), ([], {})
|
||||
self.env['base_import.import']._get_mapping_suggestions([], {}, []), {}
|
||||
)
|
||||
|
||||
def test_nomatch(self):
|
||||
self.assertEqual(
|
||||
self.env['base_import.import']._match_headers(
|
||||
iter([
|
||||
['foo', 'bar', 'baz', 'qux'],
|
||||
['v1', 'v2', 'v3', 'v4'],
|
||||
]),
|
||||
[],
|
||||
{'headers': True}),
|
||||
(
|
||||
self.env['base_import.import']._get_mapping_suggestions(
|
||||
['foo', 'bar', 'baz', 'qux'],
|
||||
dict.fromkeys(range(4))
|
||||
)
|
||||
{
|
||||
(0, 'foo'): ['int'],
|
||||
(1, 'bar'): ['char'],
|
||||
(2, 'baz'): ['text'],
|
||||
(3, 'qux'): ['many2one']
|
||||
},
|
||||
{}),
|
||||
{
|
||||
(0, 'foo'): None,
|
||||
(1, 'bar'): None,
|
||||
(2, 'baz'): None,
|
||||
(3, 'qux'): None
|
||||
}
|
||||
)
|
||||
|
||||
def test_mixed(self):
|
||||
self.assertEqual(
|
||||
self.env['base_import.import']._match_headers(
|
||||
iter(['foo bar baz qux/corge'.split()]),
|
||||
self.env['base_import.import']._get_mapping_suggestions(
|
||||
'foo bar baz qux/corge'.split(),
|
||||
{
|
||||
(0, 'foo'): ['int'],
|
||||
(1, 'bar'): ['char'],
|
||||
(2, 'baz'): ['text'],
|
||||
(3, 'qux/corge'): ['text']
|
||||
},
|
||||
[
|
||||
{'name': 'bar', 'string': 'Bar'},
|
||||
{'name': 'bob', 'string': 'Baz'},
|
||||
{'name': 'qux', 'string': 'Qux', 'fields': [
|
||||
{'name': 'corge', 'fields': []},
|
||||
{'name': 'bar', 'string': 'Bar', 'type': 'char'},
|
||||
{'name': 'bob', 'string': 'Baz', 'type': 'text'},
|
||||
{'name': 'qux', 'string': 'Qux', 'type': 'many2one', 'fields': [
|
||||
{'name': 'corge', 'type': 'text', 'fields': []},
|
||||
]}
|
||||
],
|
||||
{'headers': True}),
|
||||
(['foo', 'bar', 'baz', 'qux/corge'], {
|
||||
0: None,
|
||||
1: ['bar'],
|
||||
2: ['bob'],
|
||||
3: ['qux', 'corge'],
|
||||
})
|
||||
]),
|
||||
{
|
||||
(0, 'foo'): None,
|
||||
(1, 'bar'): {'field_path': ['bar'], 'distance': 0},
|
||||
(2, 'baz'): {'field_path': ['bob'], 'distance': 0},
|
||||
(3, 'qux/corge'): {'field_path': ['qux', 'corge']}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -225,10 +259,10 @@ class TestColumnMapping(TransactionCase):
|
||||
'file_type': 'text/csv',
|
||||
'file_name': 'data.csv',
|
||||
})
|
||||
import_record.do(
|
||||
import_record.execute_import(
|
||||
['name', 'somevalue', 'othervalue'],
|
||||
['Name', 'Some Value', 'value'],
|
||||
{'quoting': '"', 'separator': ',', 'headers': True},
|
||||
{'quoting': '"', 'separator': ',', 'has_headers': True},
|
||||
True
|
||||
)
|
||||
fields = self.env['base_import.mapping'].search_read(
|
||||
@@ -238,6 +272,29 @@ class TestColumnMapping(TransactionCase):
|
||||
self.assertItemsEqual([f['column_name'] for f in fields], ['Name', 'Some Value', 'value'])
|
||||
self.assertItemsEqual([f['field_name'] for f in fields], ['somevalue', 'name', 'othervalue'])
|
||||
|
||||
def test_fuzzy_match_distance(self):
|
||||
values_to_test = [
|
||||
('opportunities', 'opportinuties'),
|
||||
('opportunities', 'opportunate'),
|
||||
('opportunities', 'operable'),
|
||||
('opportunities', 'purchasing'),
|
||||
('lead_id', 'laed_id'),
|
||||
('lead_id', 'leen_id'),
|
||||
('lead_id', 'let_id_be'),
|
||||
('lead_id', 'not related'),
|
||||
]
|
||||
|
||||
Import = self.env['base_import.import']
|
||||
max_distance = 0.2 # see FUZZY_MATCH_DISTANCE. We don't use it here to avoid making test work after modifying this constant.
|
||||
for value in values_to_test:
|
||||
distance = Import._get_distance(value[0].lower(), value[1].lower())
|
||||
model_fields_info = [{'name': value[0], 'string': value[0], 'type': 'char'}]
|
||||
match = self.env['base_import.import']._get_mapping_suggestion(value[1], model_fields_info, ['char'], {})
|
||||
|
||||
self.assertEqual(
|
||||
bool(match), distance < max_distance
|
||||
)
|
||||
|
||||
|
||||
class TestPreview(TransactionCase):
|
||||
|
||||
@@ -279,8 +336,8 @@ class TestPreview(TransactionCase):
|
||||
import_wizard = self.env['base_import.import'].create({
|
||||
'res_model': 'base_import.tests.models.preview',
|
||||
'file': b'name,Some Value,Counter\n'
|
||||
b'foo,1,2\n'
|
||||
b'bar,3,4\n'
|
||||
b'foo,,\n'
|
||||
b'bar,,4\n'
|
||||
b'qux,5,6\n',
|
||||
'file_type': 'text/csv'
|
||||
})
|
||||
@@ -288,23 +345,19 @@ class TestPreview(TransactionCase):
|
||||
result = import_wizard.parse_preview({
|
||||
'quoting': '"',
|
||||
'separator': ',',
|
||||
'headers': True,
|
||||
'has_headers': True,
|
||||
})
|
||||
self.assertIsNone(result.get('error'))
|
||||
self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None})
|
||||
self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue']})
|
||||
self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter'])
|
||||
# Order depends on iteration order of fields_get
|
||||
self.assertItemsEqual(result['fields'], [
|
||||
ID_FIELD,
|
||||
{'id': 'name', 'name': 'name', 'string': 'Name', 'required': False, 'fields': [], 'type': 'char'},
|
||||
{'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required': True, 'fields': [], 'type': 'integer'},
|
||||
{'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required': False, 'fields': [], 'type': 'integer'},
|
||||
])
|
||||
self.assertEqual(result['preview'], [
|
||||
['foo', '1', '2'],
|
||||
['bar', '3', '4'],
|
||||
['qux', '5', '6'],
|
||||
{'id': 'name', 'name': 'name', 'string': 'Name', 'required': False, 'fields': [], 'type': 'char', 'model_name': 'base_import.tests.models.preview'},
|
||||
{'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required': True, 'fields': [], 'type': 'integer', 'model_name': 'base_import.tests.models.preview'},
|
||||
{'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required': False, 'fields': [], 'type': 'integer', 'model_name': 'base_import.tests.models.preview'},
|
||||
])
|
||||
self.assertEqual(result['preview'], ['foo', '5', '4'])
|
||||
|
||||
@unittest.skipUnless(can_import('xlrd'), "XLRD module not available")
|
||||
def test_xls_success(self):
|
||||
@@ -317,22 +370,18 @@ class TestPreview(TransactionCase):
|
||||
})
|
||||
|
||||
result = import_wizard.parse_preview({
|
||||
'headers': True,
|
||||
'has_headers': True,
|
||||
})
|
||||
self.assertIsNone(result.get('error'))
|
||||
self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None})
|
||||
self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue']})
|
||||
self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter'])
|
||||
self.assertItemsEqual(result['fields'], [
|
||||
ID_FIELD,
|
||||
{'id': 'name', 'name': 'name', 'string': 'Name', 'required': False, 'fields': [], 'type': 'char'},
|
||||
{'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required': True, 'fields': [], 'type': 'integer'},
|
||||
{'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required': False, 'fields': [], 'type': 'integer'},
|
||||
])
|
||||
self.assertEqual(result['preview'], [
|
||||
['foo', '1', '2'],
|
||||
['bar', '3', '4'],
|
||||
['qux', '5', '6'],
|
||||
{'id': 'name', 'name': 'name', 'string': 'Name', 'required': False, 'fields': [], 'type': 'char', 'model_name': 'base_import.tests.models.preview'},
|
||||
{'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required': True, 'fields': [], 'type': 'integer', 'model_name': 'base_import.tests.models.preview'},
|
||||
{'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required': False, 'fields': [], 'type': 'integer', 'model_name': 'base_import.tests.models.preview'},
|
||||
])
|
||||
self.assertEqual(result['preview'], ['foo', '1', '2'])
|
||||
|
||||
@unittest.skipUnless(can_import('xlrd.xlsx'), "XLRD/XLSX not available")
|
||||
def test_xlsx_success(self):
|
||||
@@ -345,22 +394,18 @@ class TestPreview(TransactionCase):
|
||||
})
|
||||
|
||||
result = import_wizard.parse_preview({
|
||||
'headers': True,
|
||||
'has_headers': True,
|
||||
})
|
||||
self.assertIsNone(result.get('error'))
|
||||
self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None})
|
||||
self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue']})
|
||||
self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter'])
|
||||
self.assertItemsEqual(result['fields'], [
|
||||
ID_FIELD,
|
||||
{'id': 'name', 'name': 'name', 'string': 'Name', 'required': False, 'fields': [], 'type': 'char'},
|
||||
{'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required': True, 'fields': [], 'type': 'integer'},
|
||||
{'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required': False, 'fields': [], 'type': 'integer'},
|
||||
])
|
||||
self.assertEqual(result['preview'], [
|
||||
['foo', '1', '2'],
|
||||
['bar', '3', '4'],
|
||||
['qux', '5', '6'],
|
||||
{'id': 'name', 'name': 'name', 'string': 'Name', 'required': False, 'fields': [], 'type': 'char', 'model_name': 'base_import.tests.models.preview'},
|
||||
{'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required': True, 'fields': [], 'type': 'integer', 'model_name': 'base_import.tests.models.preview'},
|
||||
{'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required': False, 'fields': [], 'type': 'integer', 'model_name': 'base_import.tests.models.preview'},
|
||||
])
|
||||
self.assertEqual(result['preview'], ['foo', '1', '2'])
|
||||
|
||||
@unittest.skipUnless(can_import('odf'), "ODFPY not available")
|
||||
def test_ods_success(self):
|
||||
@@ -373,22 +418,19 @@ class TestPreview(TransactionCase):
|
||||
})
|
||||
|
||||
result = import_wizard.parse_preview({
|
||||
'headers': True,
|
||||
'has_headers': True,
|
||||
})
|
||||
self.assertIsNone(result.get('error'))
|
||||
self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None})
|
||||
self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue']})
|
||||
self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter'])
|
||||
self.assertItemsEqual(result['fields'], [
|
||||
ID_FIELD,
|
||||
{'id': 'name', 'name': 'name', 'string': 'Name', 'required': False, 'fields': [], 'type': 'char'},
|
||||
{'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required': True, 'fields': [], 'type': 'integer'},
|
||||
{'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required': False, 'fields': [], 'type': 'integer'},
|
||||
])
|
||||
self.assertEqual(result['preview'], [
|
||||
['foo', '1', '2'],
|
||||
['bar', '3', '4'],
|
||||
['aux', '5', '6'],
|
||||
{'id': 'name', 'name': 'name', 'string': 'Name', 'required': False, 'fields': [], 'type': 'char', 'model_name': 'base_import.tests.models.preview'},
|
||||
{'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required': True, 'fields': [], 'type': 'integer', 'model_name': 'base_import.tests.models.preview'},
|
||||
{'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required': False, 'fields': [], 'type': 'integer', 'model_name': 'base_import.tests.models.preview'},
|
||||
])
|
||||
self.assertEqual(result['preview'], ['foo', '1', '2'])
|
||||
|
||||
|
||||
class test_convert_import_data(TransactionCase):
|
||||
""" Tests conversion of base_import.import input into data which
|
||||
@@ -406,7 +448,7 @@ class test_convert_import_data(TransactionCase):
|
||||
})
|
||||
data, fields = import_wizard._convert_import_data(
|
||||
['name', 'somevalue', 'othervalue'],
|
||||
{'quoting': '"', 'separator': ',', 'headers': True}
|
||||
{'quoting': '"', 'separator': ',', 'has_headers': True}
|
||||
)
|
||||
|
||||
self.assertItemsEqual(fields, ['name', 'somevalue', 'othervalue'])
|
||||
@@ -425,7 +467,7 @@ class test_convert_import_data(TransactionCase):
|
||||
|
||||
})
|
||||
|
||||
results = import_wizard.do(
|
||||
results = import_wizard.execute_import(
|
||||
['name', 'date', 'create_date'],
|
||||
[],
|
||||
{
|
||||
@@ -433,7 +475,7 @@ class test_convert_import_data(TransactionCase):
|
||||
'datetime_format': '%Y-%m-%d %H:%M',
|
||||
'quoting': '"',
|
||||
'separator': ',',
|
||||
'headers': True
|
||||
'has_headers': True
|
||||
}
|
||||
)
|
||||
|
||||
@@ -457,7 +499,7 @@ class test_convert_import_data(TransactionCase):
|
||||
'separator': ',',
|
||||
'float_decimal_separator': ',',
|
||||
'float_thousand_separator': '.',
|
||||
'headers': True
|
||||
'has_headers': True
|
||||
}
|
||||
data, import_fields = import_wizard._convert_import_data(
|
||||
['name', 'parent_id/.id', 'parent_id/date', 'parent_id/credit_limit'],
|
||||
@@ -503,7 +545,7 @@ class test_convert_import_data(TransactionCase):
|
||||
})
|
||||
data, fields = import_wizard._convert_import_data(
|
||||
['name', False, 'othervalue'],
|
||||
{'quoting': '"', 'separator': ',', 'headers': True}
|
||||
{'quoting': '"', 'separator': ',', 'has_headers': True}
|
||||
)
|
||||
|
||||
self.assertItemsEqual(fields, ['name', 'othervalue'])
|
||||
@@ -527,7 +569,7 @@ class test_convert_import_data(TransactionCase):
|
||||
})
|
||||
data, fields = import_wizard._convert_import_data(
|
||||
['name', False, 'othervalue'],
|
||||
{'quoting': '"', 'separator': ',', 'headers': True}
|
||||
{'quoting': '"', 'separator': ',', 'has_headers': True}
|
||||
)
|
||||
|
||||
self.assertItemsEqual(fields, ['name', 'othervalue'])
|
||||
@@ -549,7 +591,7 @@ class test_convert_import_data(TransactionCase):
|
||||
})
|
||||
data, fields = import_wizard._convert_import_data(
|
||||
['name', 'somevalue'],
|
||||
{'quoting': '"', 'separator': ',', 'headers': True}
|
||||
{'quoting': '"', 'separator': ',', 'has_headers': True}
|
||||
)
|
||||
|
||||
self.assertItemsEqual(fields, ['name', 'somevalue'])
|
||||
@@ -566,7 +608,7 @@ class test_convert_import_data(TransactionCase):
|
||||
'file_type': 'text/csv'
|
||||
|
||||
})
|
||||
self.assertRaises(ValueError, import_wizard._convert_import_data, [], {'quoting': '"', 'separator': ',', 'headers': True})
|
||||
self.assertRaises(ImportValidationError, import_wizard._convert_import_data, [], {'quoting': '"', 'separator': ',', 'has_headers': True})
|
||||
|
||||
def test_falsefields(self):
|
||||
import_wizard = self.env['base_import.import'].create({
|
||||
@@ -577,10 +619,10 @@ class test_convert_import_data(TransactionCase):
|
||||
})
|
||||
|
||||
self.assertRaises(
|
||||
ValueError,
|
||||
ImportValidationError,
|
||||
import_wizard._convert_import_data,
|
||||
[False, False, False],
|
||||
{'quoting': '"', 'separator': ',', 'headers': True})
|
||||
{'quoting': '"', 'separator': ',', 'has_headers': True})
|
||||
|
||||
def test_newline_import(self):
|
||||
"""
|
||||
@@ -601,11 +643,12 @@ class test_convert_import_data(TransactionCase):
|
||||
})
|
||||
data, _ = import_wizard._convert_import_data(
|
||||
['name', 'somevalue'],
|
||||
{'quoting': '"', 'separator': ',', 'headers': True}
|
||||
{'quoting': '"', 'separator': ',', 'has_headers': True}
|
||||
)
|
||||
|
||||
self.assertItemsEqual(data, [data_row])
|
||||
|
||||
|
||||
class TestBatching(TransactionCase):
|
||||
def _makefile(self, rows):
|
||||
f = io.BytesIO()
|
||||
@@ -625,7 +668,7 @@ class TestBatching(TransactionCase):
|
||||
result = import_wizard.parse_preview({
|
||||
'quoting': '"',
|
||||
'separator': ',',
|
||||
'headers': True,
|
||||
'has_headers': True,
|
||||
'limit': 100,
|
||||
})
|
||||
self.assertIsNone(result.get('error'))
|
||||
@@ -634,7 +677,7 @@ class TestBatching(TransactionCase):
|
||||
result = import_wizard.parse_preview({
|
||||
'quoting': '"',
|
||||
'separator': ',',
|
||||
'headers': True,
|
||||
'has_headers': True,
|
||||
'limit': 5,
|
||||
})
|
||||
self.assertIsNone(result.get('error'))
|
||||
@@ -669,11 +712,11 @@ class TestBatching(TransactionCase):
|
||||
'file_name': 'things.csv',
|
||||
'file': f.getvalue(),
|
||||
})
|
||||
opts = {'quoting': '"', 'separator': ',', 'headers': True}
|
||||
opts = {'quoting': '"', 'separator': ',', 'has_headers': True}
|
||||
preview = import_wizard.parse_preview({**opts, 'limit': 15})
|
||||
self.assertIs(preview['batch'], True)
|
||||
|
||||
results = import_wizard.do(
|
||||
results = import_wizard.execute_import(
|
||||
['name', 'value/value'], [],
|
||||
{**opts, 'limit': 5}
|
||||
)
|
||||
@@ -681,7 +724,7 @@ class TestBatching(TransactionCase):
|
||||
self.assertEqual(len(results['ids']), 1, "should have imported the first record in full, got %s" % results['ids'])
|
||||
self.assertEqual(results['nextrow'], 10)
|
||||
|
||||
results = import_wizard.do(
|
||||
results = import_wizard.execute_import(
|
||||
['name', 'value/value'], [],
|
||||
{**opts, 'limit': 15}
|
||||
)
|
||||
@@ -692,7 +735,7 @@ class TestBatching(TransactionCase):
|
||||
|
||||
def test_batches(self):
|
||||
partners_before = self.env['res.partner'].search([])
|
||||
opts = {'headers': True, 'separator': ',', 'quoting': '"'}
|
||||
opts = {'has_headers': True, 'separator': ',', 'quoting': '"'}
|
||||
|
||||
import_wizard = self.env['base_import.import'].create({
|
||||
'res_model': 'res.partner',
|
||||
@@ -710,7 +753,7 @@ g,g@example.com
|
||||
"""
|
||||
})
|
||||
|
||||
results = import_wizard.do(['name', 'email'], [], {**opts, 'limit': 1})
|
||||
results = import_wizard.execute_import(['name', 'email'], [], {**opts, 'limit': 1})
|
||||
self.assertFalse(results['messages'])
|
||||
self.assertEqual(len(results['ids']), 1)
|
||||
# titlerow is ignored by lastrow's counter
|
||||
@@ -718,7 +761,7 @@ g,g@example.com
|
||||
partners_1 = self.env['res.partner'].search([]) - partners_before
|
||||
self.assertEqual(partners_1.name, 'a')
|
||||
|
||||
results = import_wizard.do(['name', 'email'], [], {**opts, 'limit': 2, 'skip': 1})
|
||||
results = import_wizard.execute_import(['name', 'email'], [], {**opts, 'limit': 2, 'skip': 1})
|
||||
self.assertFalse(results['messages'])
|
||||
self.assertEqual(len(results['ids']), 2)
|
||||
# empty row should also be ignored
|
||||
@@ -726,13 +769,14 @@ g,g@example.com
|
||||
partners_2 = self.env['res.partner'].search([]) - (partners_before | partners_1)
|
||||
self.assertEqual(partners_2.mapped('name'), ['b', 'c'])
|
||||
|
||||
results = import_wizard.do(['name', 'email'], [], {**opts, 'limit': 10, 'skip': 3})
|
||||
results = import_wizard.execute_import(['name', 'email'], [], {**opts, 'limit': 10, 'skip': 3})
|
||||
self.assertFalse(results['messages'])
|
||||
self.assertEqual(len(results['ids']), 4)
|
||||
self.assertEqual(results['nextrow'], 0)
|
||||
partners_3 = self.env['res.partner'].search([]) - (partners_before | partners_1 | partners_2)
|
||||
self.assertEqual(partners_3.mapped('name'), ['d', 'e', 'f', 'g'])
|
||||
|
||||
|
||||
class test_failures(TransactionCase):
|
||||
def test_big_attachments(self):
|
||||
"""
|
||||
@@ -755,8 +799,8 @@ class test_failures(TransactionCase):
|
||||
'file': fout.getvalue(),
|
||||
'file_type': 'text/csv'
|
||||
})
|
||||
results = import_wizard.do(
|
||||
results = import_wizard.execute_import(
|
||||
['name', 'db_datas'],
|
||||
[],
|
||||
{'headers': True, 'separator': ',', 'quoting': '"'})
|
||||
{'has_headers': True, 'separator': ',', 'quoting': '"'})
|
||||
self.assertFalse(results['messages'], "results should be empty on successful import")
|
||||
|
||||
@@ -6,6 +6,7 @@ import codecs
|
||||
|
||||
from odoo.tests import common
|
||||
|
||||
|
||||
class ImportCase(common.TransactionCase):
|
||||
def _make_import(self, contents):
|
||||
return self.env['base_import.import'].create({
|
||||
@@ -58,7 +59,7 @@ class TestEncoding(ImportCase):
|
||||
auto-detection
|
||||
"""
|
||||
s = "Iñtërnâtiônàlizætiøn".encode('utf-8')
|
||||
r = self._make_import(b'text\n' + s)\
|
||||
r = self._make_import(s + b'\ntext')\
|
||||
.parse_preview({
|
||||
'quoting': '"',
|
||||
'separator': '\t',
|
||||
@@ -66,7 +67,8 @@ class TestEncoding(ImportCase):
|
||||
})
|
||||
self.assertIsNone(r.get('error'))
|
||||
self.assertEqual(r['options']['encoding'], 'iso-8859-1')
|
||||
self.assertEqual(r['preview'], [['text'], [s.decode('iso-8859-1')]])
|
||||
self.assertEqual(r['preview'], [s.decode('iso-8859-1')])
|
||||
|
||||
|
||||
class TestFileSeparator(ImportCase):
|
||||
|
||||
@@ -83,17 +85,12 @@ d|4
|
||||
def test_explicit_success(self):
|
||||
r = self.imp.parse_preview({
|
||||
'separator': '|',
|
||||
'headers': True,
|
||||
'has_headers': True,
|
||||
'quoting': '"',
|
||||
})
|
||||
self.assertIsNone(r.get('error'))
|
||||
self.assertEqual(r['headers'], ['c', 'f'])
|
||||
self.assertEqual(r['preview'], [
|
||||
['a', '1'],
|
||||
['b', '2'],
|
||||
['c', '3'],
|
||||
['d', '4'],
|
||||
])
|
||||
self.assertEqual(r['preview'], ['a', '1'])
|
||||
self.assertEqual(r['options']['separator'], '|')
|
||||
|
||||
def test_explicit_fail(self):
|
||||
@@ -101,33 +98,23 @@ d|4
|
||||
"""
|
||||
r = self.imp.parse_preview({
|
||||
'separator': ',',
|
||||
'headers': True,
|
||||
'has_headers': True,
|
||||
'quoting': '"',
|
||||
})
|
||||
self.assertIsNone(r.get('error'))
|
||||
self.assertEqual(r['headers'], ['c|f'])
|
||||
self.assertEqual(r['preview'], [
|
||||
['a|1'],
|
||||
['b|2'],
|
||||
['c|3'],
|
||||
['d|4'],
|
||||
])
|
||||
self.assertEqual(r['preview'], ['a|1'])
|
||||
self.assertEqual(r['options']['separator'], ',')
|
||||
|
||||
def test_guess_ok(self):
|
||||
r = self.imp.parse_preview({
|
||||
'separator': '',
|
||||
'headers': True,
|
||||
'has_headers': True,
|
||||
'quoting': '"',
|
||||
})
|
||||
self.assertIsNone(r.get('error'))
|
||||
self.assertEqual(r['headers'], ['c', 'f'])
|
||||
self.assertEqual(r['preview'], [
|
||||
['a', '1'],
|
||||
['b', '2'],
|
||||
['c', '3'],
|
||||
['d', '4'],
|
||||
])
|
||||
self.assertEqual(r['preview'], ['a', '1'])
|
||||
self.assertEqual(r['options']['separator'], '|')
|
||||
|
||||
def test_noguess(self):
|
||||
@@ -137,19 +124,15 @@ d|4
|
||||
imp = self._make_import('c\na\nb\nc\nd')
|
||||
r = imp.parse_preview({
|
||||
'separator': '',
|
||||
'headers': True,
|
||||
'has_headers': True,
|
||||
'quoting': '"',
|
||||
})
|
||||
self.assertIsNone(r.get('error'))
|
||||
self.assertEqual(r['headers'], ['c'])
|
||||
self.assertEqual(r['preview'], [
|
||||
['a'],
|
||||
['b'],
|
||||
['c'],
|
||||
['d'],
|
||||
])
|
||||
self.assertEqual(r['preview'], ['a'])
|
||||
self.assertEqual(r['options']['separator'], '')
|
||||
|
||||
|
||||
class TestNumberSeparators(common.TransactionCase):
|
||||
def test_parse_float(self):
|
||||
w = self.env['base_import.import'].create({
|
||||
|
||||
@@ -42,6 +42,41 @@ class IrFieldsConverter(models.AbstractModel):
|
||||
error_params = tuple(sanitize(v) for v in error_params)
|
||||
return error_type(error_msg % error_params, error_args)
|
||||
|
||||
def _get_import_field_path(self, field, value):
|
||||
""" Rebuild field path for import error attribution to the right field.
|
||||
This method uses the 'parent_fields_hierarchy' context key built during treatment of one2many fields
|
||||
(_str_to_one2many). As the field to import is the last of the chain (child_id/child_id2/field_to_import),
|
||||
we need to retrieve the complete hierarchy in case of error in order to assign the error to the correct
|
||||
column in the import UI.
|
||||
|
||||
:param (str) field: field in which the value will be imported.
|
||||
:param (str or list) value:
|
||||
- str: in most of the case the value we want to import into a field is a string (or a number).
|
||||
- list: when importing into a one2may field, all the records to import are regrouped into a list of dict.
|
||||
E.g.: creating multiple partners: [{None: 'ChildA_1', 'type': 'Private address'}, {None: 'ChildA_2', 'type': 'Private address'}]
|
||||
where 'None' is the name. (because we can find a partner by his name, we don't need to specify the field.)
|
||||
|
||||
The field_path value is computed based on the last field in the chain.
|
||||
for example,
|
||||
- path_field for 'Private address' at childA_1 is ['partner_id', 'type']
|
||||
- path_field for 'childA_1' is ['partner_id']
|
||||
|
||||
So, by retrieving the correct field_path for each value to import, if errors are raised for those fields,
|
||||
we can the link the errors to the correct header-field couple in the import UI.
|
||||
"""
|
||||
field_path = [field]
|
||||
parent_fields_hierarchy = self._context.get('parent_fields_hierarchy')
|
||||
if parent_fields_hierarchy:
|
||||
field_path = parent_fields_hierarchy + field_path
|
||||
|
||||
field_path_value = value
|
||||
while isinstance(field_path_value, list):
|
||||
key = list(field_path_value[0].keys())[0]
|
||||
if key:
|
||||
field_path.append(key)
|
||||
field_path_value = field_path_value[0][key]
|
||||
return field_path
|
||||
|
||||
@api.model
|
||||
def for_model(self, model, fromtype=str):
|
||||
""" Returns a converter object for the model. A converter is a
|
||||
@@ -63,6 +98,7 @@ class IrFieldsConverter(models.AbstractModel):
|
||||
|
||||
def fn(record, log):
|
||||
converted = {}
|
||||
import_file_context = self.env.context.get('import_file')
|
||||
for field, value in record.items():
|
||||
if field in REFERENCING_FIELDS:
|
||||
continue
|
||||
@@ -78,6 +114,21 @@ class IrFieldsConverter(models.AbstractModel):
|
||||
w = ImportWarning(w)
|
||||
log(field, w)
|
||||
except ValueError as e:
|
||||
if import_file_context:
|
||||
# if the error is linked to a matching error, the error is a tuple
|
||||
# E.g.:("Value X cannot be found for field Y at row 1", {
|
||||
# 'more_info': {},
|
||||
# 'value': 'X',
|
||||
# 'field': 'Y',
|
||||
# 'field_path': child_id/Y,
|
||||
# })
|
||||
# In order to link the error to the correct header-field couple in the import UI, we need to add
|
||||
# the field path to the additional error info.
|
||||
# As we raise the deepest child in error, we need to add the field path only for the deepest
|
||||
# error in the import recursion. (if field_path is given, don't overwrite it)
|
||||
error_info = len(e.args) > 1 and e.args[1]
|
||||
if error_info and not error_info.get('field_path'): # only raise the deepest child in error
|
||||
error_info['field_path'] = self._get_import_field_path(field, value)
|
||||
log(field, e)
|
||||
return converted
|
||||
|
||||
@@ -134,7 +185,6 @@ class IrFieldsConverter(models.AbstractModel):
|
||||
@api.model
|
||||
def _str_to_boolean(self, model, field, value):
|
||||
# all translatables used for booleans
|
||||
true, yes, false, no = _(u"true"), _(u"yes"), _(u"false"), _(u"no")
|
||||
# potentially broken casefolding? What about locales?
|
||||
trues = set(word.lower() for word in itertools.chain(
|
||||
[u'1', u"true", u"yes"], # don't use potentially translated values
|
||||
@@ -154,9 +204,9 @@ class IrFieldsConverter(models.AbstractModel):
|
||||
return False, []
|
||||
|
||||
return True, [self._format_import_error(
|
||||
ImportWarning,
|
||||
_(u"Unknown value '%s' for boolean field '%%(field)s', assuming '%s'"),
|
||||
(value, yes),
|
||||
ValueError,
|
||||
_(u"Unknown value '%s' for boolean field '%%(field)s'"),
|
||||
value,
|
||||
{'moreinfo': _(u"Use '1' for yes and '0' for no")}
|
||||
)]
|
||||
|
||||
@@ -263,7 +313,9 @@ class IrFieldsConverter(models.AbstractModel):
|
||||
for item, label in selection:
|
||||
label = ustr(label)
|
||||
labels = [label] + self._get_translations(('selection', 'model', 'code'), label)
|
||||
if value == str(item) or value in labels:
|
||||
# case insensitive comparaison of string to allow to set the value even if the given 'value' param is not
|
||||
# exactly (case sensitive) the same as one of the selection item.
|
||||
if value.lower() == str(item).lower() or any(value.lower() == label.lower() for label in labels):
|
||||
return item, []
|
||||
|
||||
raise self._format_import_error(
|
||||
@@ -347,8 +399,8 @@ class IrFieldsConverter(models.AbstractModel):
|
||||
if ids:
|
||||
if len(ids) > 1:
|
||||
warnings.append(ImportWarning(
|
||||
_(u"Found multiple matches for field '%%(field)s' (%d matches)")
|
||||
% (len(ids))))
|
||||
_(u"Found multiple matches for value '%s' in field '%%(field)s' (%d matches)")
|
||||
% (value, len(ids))))
|
||||
id, _name = ids[0]
|
||||
else:
|
||||
name_create_enabled_fields = self.env.context.get('name_create_enabled_fields') or {}
|
||||
@@ -364,16 +416,29 @@ class IrFieldsConverter(models.AbstractModel):
|
||||
subfield
|
||||
)
|
||||
|
||||
if id is None:
|
||||
import_skip = False
|
||||
if self.env.context.get('import_file'):
|
||||
import_skip_fields = self.env.context.get('import_skip_fields') or []
|
||||
field_path = "/".join((self.env.context.get('parent_fields_hierarchy', []) + [field.name]))
|
||||
import_skip = field_path in import_skip_fields
|
||||
if id is None and not import_skip:
|
||||
if error_msg:
|
||||
message = _("No matching record found for %(field_type)s '%(value)s' in field '%%(field)s' and the following error was encountered when we attempted to create one: %(error_message)s")
|
||||
else:
|
||||
message = _("No matching record found for %(field_type)s '%(value)s' in field '%%(field)s'")
|
||||
|
||||
error_info_dict = {'moreinfo': action}
|
||||
if self.env.context.get('import_file'):
|
||||
# limit to 50 char to avoid too long error messages.
|
||||
value = value[:50] if isinstance(value, str) else value
|
||||
error_info_dict.update({'value': value, 'field_type': field_type})
|
||||
if error_msg:
|
||||
error_info_dict['error_message'] = error_msg
|
||||
raise self._format_import_error(
|
||||
ValueError,
|
||||
message,
|
||||
{'field_type': field_type, 'value': value, 'error_message': error_msg},
|
||||
{'moreinfo': action})
|
||||
error_info_dict)
|
||||
return id, field_type, warnings
|
||||
|
||||
def _xmlid_to_record_id(self, xmlid, model):
|
||||
@@ -485,7 +550,14 @@ class IrFieldsConverter(models.AbstractModel):
|
||||
raise exception
|
||||
warnings.append(exception)
|
||||
|
||||
convert = self.with_context(name_create_enabled_fields=relative_name_create_enabled_fields).for_model(self.env[field.comodel_name])
|
||||
# Complete the field hierarchy path
|
||||
# E.g. For "parent/child/subchild", field hierarchy path for "subchild" is ['parent', 'child']
|
||||
parent_fields_hierarchy = self._context.get('parent_fields_hierarchy', []) + [field.name]
|
||||
|
||||
convert = self.with_context(
|
||||
name_create_enabled_fields=relative_name_create_enabled_fields,
|
||||
parent_fields_hierarchy=parent_fields_hierarchy
|
||||
).for_model(self.env[field.comodel_name])
|
||||
|
||||
for record in records:
|
||||
id = None
|
||||
|
||||
@@ -43,6 +43,10 @@ class ImporterCase(common.TransactionCase):
|
||||
self.cr.cache.clear()
|
||||
|
||||
def import_(self, fields, rows, context=None):
|
||||
context = context or {}
|
||||
context.update({
|
||||
'import_file': True
|
||||
})
|
||||
return self.model.with_context(context or {}).load(fields, rows)
|
||||
|
||||
def read(self, fields=('value',), domain=(), context=None):
|
||||
@@ -169,20 +173,23 @@ class test_boolean_field(ImporterCase):
|
||||
self.assertEqual([False] * len(falses), values(self.read()))
|
||||
|
||||
def test_trues(self):
|
||||
# Since importing wrong boolean values is now returning error, import should not return any ids if an error is raised.
|
||||
trues = [['None'], ['nil'], ['()'], ['f'], ['#f'],
|
||||
# Problem: OpenOffice (and probably excel) output localized booleans
|
||||
['VRAI'], ['ok'], ['true'], ['yes'], ['1'], ]
|
||||
result = self.import_(['value'], trues)
|
||||
self.assertEqual(len(result['ids']), 10)
|
||||
self.assertEqual(result['messages'], [
|
||||
message(u"Unknown value '%s' for boolean field 'Value', assuming 'yes'" % v[0],
|
||||
moreinfo=u"Use '1' for yes and '0' for no",
|
||||
type='warning', from_=i, to_=i, record=i)
|
||||
for i, v in enumerate(trues)
|
||||
if v[0] not in ('true', 'yes', '1')
|
||||
])
|
||||
self.assertEqual(result['ids'], False)
|
||||
self.assertEqual(result['messages'], [{
|
||||
'rows': {'from': i, 'to': i}, 'type': 'error', 'record': i, 'field': 'value',
|
||||
'message': "Unknown value '%s' for boolean field 'Value'" % v[0],
|
||||
'moreinfo': "Use '1' for yes and '0' for no", 'field_name': 'Value'
|
||||
} for i, v in enumerate(trues) if v[0] not in ('true', 'yes', '1')])
|
||||
|
||||
# Only correct boolean values are accepted.
|
||||
result = self.import_(['value'], [['1'], ['yes'], ['true']])
|
||||
self.assertEqual(len(result['ids']), 3)
|
||||
self.assertEqual(
|
||||
[True] * 10,
|
||||
[True] * 3,
|
||||
values(self.read()))
|
||||
|
||||
|
||||
@@ -265,6 +272,7 @@ class test_integer_field(ImporterCase):
|
||||
result = self.import_(['value'], [['zorglub']])
|
||||
self.assertIs(result['ids'], False)
|
||||
self.assertEqual(result['messages'], [{
|
||||
'field_name': 'Value',
|
||||
'type': 'error',
|
||||
'rows': {'from': 0, 'to': 0},
|
||||
'record': 0,
|
||||
@@ -336,7 +344,7 @@ class test_float_field(ImporterCase):
|
||||
result = self.import_(['value'], [['foobar']])
|
||||
self.assertIs(result['ids'], False)
|
||||
self.assertEqual(result['messages'], [
|
||||
message(u"'foobar' does not seem to be a number for field 'Value'")])
|
||||
message(u"'foobar' does not seem to be a number for field 'Value'", field_name='Value')])
|
||||
|
||||
class test_string_field(ImporterCase):
|
||||
model_name = 'export.string.bounded'
|
||||
@@ -480,13 +488,14 @@ class test_selection(ImporterCase):
|
||||
self.assertIs(result['ids'], False)
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"Value 'Baz' not found in selection field 'Value'",
|
||||
moreinfo="Foo Bar Qux 4".split())])
|
||||
moreinfo="Foo Bar Qux 4".split(), field_name='Value', field_path=['value'])])
|
||||
|
||||
result = self.import_(['value'], [['42']])
|
||||
self.assertIs(result['ids'], False)
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"Value '42' not found in selection field 'Value'",
|
||||
moreinfo="Foo Bar Qux 4".split())])
|
||||
moreinfo="Foo Bar Qux 4".split(),
|
||||
field_name='Value', field_path=['value'])])
|
||||
|
||||
|
||||
class test_selection_with_default(ImporterCase):
|
||||
@@ -627,7 +636,7 @@ class test_m2o(ImporterCase):
|
||||
result = self.import_(['value'], [[name2]])
|
||||
self.assertEqual(
|
||||
result['messages'],
|
||||
[message(u"Found multiple matches for field 'Value' (2 matches)",
|
||||
[message(u"Found multiple matches for value 'export.integer:42' in field 'Value' (2 matches)",
|
||||
type='warning')])
|
||||
self.assertEqual(len(result['ids']), 1)
|
||||
self.assertEqual([
|
||||
@@ -651,7 +660,8 @@ class test_m2o(ImporterCase):
|
||||
self.assertEqual(result['messages'], [
|
||||
message(u"No matching record found for name '%s' in field 'Value'" % id,
|
||||
from_=index, to_=index, record=index,
|
||||
moreinfo=moreaction(res_model='export.integer'))
|
||||
moreinfo=moreaction(res_model='export.integer'), field_name='Value', field_path=['value'],
|
||||
field_type='name', value=id)
|
||||
for index, id in enumerate([record1.id, record2.id, record1.id])])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
@@ -662,7 +672,8 @@ class test_m2o(ImporterCase):
|
||||
self.assertEqual(result['messages'], [
|
||||
message(u"Invalid database id 'foo' for the field 'Value'",
|
||||
moreinfo=moreaction(res_model='ir.model.data',
|
||||
domain=[('model','=','export.integer')]))
|
||||
domain=[('model', '=', 'export.integer')]),
|
||||
field_name='Value', field_path=['value', '.id'])
|
||||
])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
@@ -681,21 +692,25 @@ class test_m2o(ImporterCase):
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"No matching record found for name 'nameisnoexist:3' "
|
||||
u"in field 'Value'", moreinfo=moreaction(
|
||||
res_model='export.integer'))])
|
||||
res_model='export.integer'),
|
||||
field_name='Value', field_path=['value'], field_type='name', value='nameisnoexist:3'
|
||||
)])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
result = self.import_(['value/id'], [['noxidhere']])
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"No matching record found for external id 'noxidhere' "
|
||||
u"in field 'Value'", moreinfo=moreaction(
|
||||
res_model='ir.model.data', domain=[('model','=','export.integer')]))])
|
||||
res_model='ir.model.data', domain=[('model', '=', 'export.integer')]),
|
||||
field_name='Value', field_path=['value', 'id'], field_type="external id", value="noxidhere")])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
result = self.import_(['value/.id'], [['66']])
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"No matching record found for database id '66' "
|
||||
u"in field 'Value'", moreinfo=moreaction(
|
||||
res_model='ir.model.data', domain=[('model','=','export.integer')]))])
|
||||
res_model='ir.model.data', domain=[('model', '=', 'export.integer')]),
|
||||
field_name='Value', field_path=['value', '.id'], field_type="database id", value="66")])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
def test_fail_multiple(self):
|
||||
@@ -712,7 +727,8 @@ class test_m2o(ImporterCase):
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"No matching record found for name '101' "
|
||||
u"in field 'Value'", moreinfo=moreaction(
|
||||
res_model='export.integer'))])
|
||||
res_model='export.integer'),
|
||||
field_name='Value', field_path=['value'], field_type='name', value=101)])
|
||||
self.assertIs(result['ids'], False)
|
||||
context = {
|
||||
'name_create_enabled_fields': {'value': True},
|
||||
@@ -758,7 +774,8 @@ class test_m2m(ImporterCase):
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"No matching record found for database id '42' in field "
|
||||
u"'Value'", moreinfo=moreaction(
|
||||
res_model='ir.model.data', domain=[('model','=','export.many2many.other')]))])
|
||||
res_model='ir.model.data', domain=[('model', '=', 'export.many2many.other')]),
|
||||
field_name='Value', field_path=['value', '.id'], field_type="database id", value='42')])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
def test_xids(self):
|
||||
@@ -784,7 +801,8 @@ class test_m2m(ImporterCase):
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"No matching record found for external id 'noxidforthat' in field"
|
||||
u" 'Value'", moreinfo=moreaction(
|
||||
res_model='ir.model.data', domain=[('model','=','export.many2many.other')]))])
|
||||
res_model='ir.model.data', domain=[('model', '=', 'export.many2many.other')]),
|
||||
field_name='Value', field_path=['value', 'id'], field_type='external id', value='noxidforthat')])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
def test_names(self):
|
||||
@@ -812,7 +830,8 @@ class test_m2m(ImporterCase):
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"No matching record found for name 'wherethem2mhavenonames' in "
|
||||
u"field 'Value'", moreinfo=moreaction(
|
||||
res_model='export.many2many.other'))])
|
||||
res_model='export.many2many.other'),
|
||||
field_name='Value', field_path=['value'], field_type="name", value='wherethem2mhavenonames')])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
def test_import_to_existing(self):
|
||||
@@ -845,8 +864,9 @@ class test_o2m(ImporterCase):
|
||||
['const', 'value'],
|
||||
[['5', s]])
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"No matching record found for name '%s' in field 'Value'" % s,
|
||||
moreinfo=moreaction(res_model='export.one2many.child'))])
|
||||
u"No matching record found for name '%s' in field 'Value'" % s[:50],
|
||||
moreinfo=moreaction(res_model='export.one2many.child'),
|
||||
field_name='Value', field_path=['value'], field_type='name', value=s[:50])])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
def test_single(self):
|
||||
@@ -907,8 +927,8 @@ class test_o2m(ImporterCase):
|
||||
result = self.import_(['value/parent_id'], [['noxidforthat']])
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"No matching record found for name 'noxidforthat' in field 'Value/Parent'",
|
||||
moreinfo=moreaction(res_model='export.one2many')
|
||||
)])
|
||||
moreinfo=moreaction(res_model='export.one2many'),
|
||||
field_name='Value', field_path=['value', 'parent_id'], field_type='name', value='noxidforthat')])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
def test_link_inline(self):
|
||||
@@ -999,7 +1019,8 @@ class test_o2m(ImporterCase):
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"No matching record found for name '101' "
|
||||
u"in field 'Value/M2O'", moreinfo=moreaction(
|
||||
res_model='export.integer'))])
|
||||
res_model='export.integer'),
|
||||
field_name='Value', field_path=['value', 'm2o'], field_type='name', value=101)])
|
||||
self.assertEqual(result['ids'], False)
|
||||
context = {
|
||||
'name_create_enabled_fields': {'value/m2o': True},
|
||||
@@ -1117,13 +1138,14 @@ class test_realworld(SavepointCaseWithUserDemo):
|
||||
def test_o2m_subfields_fail_by_implicit_id(self):
|
||||
self.env['ir.model.data'].clear_caches()
|
||||
Model = self.env['export.one2many.recursive']
|
||||
result = Model.load(
|
||||
result = Model.with_context(import_file=True).load(
|
||||
['child/child1/parent_id'],
|
||||
[['5'],],
|
||||
)
|
||||
self.assertEqual(result['messages'], [message(
|
||||
u"No matching record found for name '5' in field 'Child/Child1/Parent'", field='child',
|
||||
moreinfo=moreaction(res_model='export.one2many.multiple'))])
|
||||
moreinfo=moreaction(res_model='export.one2many.multiple'),
|
||||
field_name='Child', field_path=['child', 'child1', 'parent_id'], field_type='name', value='5')])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
|
||||
@@ -1145,7 +1167,7 @@ class test_date(ImporterCase):
|
||||
self.assertEqual(result['messages'], [
|
||||
message(u"'not really a date' does not seem to be a valid date "
|
||||
u"for field 'Value'",
|
||||
moreinfo=u"Use the format '2012-12-31'")])
|
||||
moreinfo=u"Use the format '2012-12-31'", field_name='Value', field_path=['value'])])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
|
||||
@@ -1167,7 +1189,7 @@ class test_datetime(ImporterCase):
|
||||
self.assertEqual(result['messages'], [
|
||||
message(u"'not really a datetime' does not seem to be a valid "
|
||||
u"datetime for field 'Value'",
|
||||
moreinfo=u"Use the format '2012-12-31 23:59:59'")])
|
||||
moreinfo=u"Use the format '2012-12-31 23:59:59'", field_name='Value', field_path=['value'])])
|
||||
self.assertIs(result['ids'], False)
|
||||
|
||||
def test_checktz1(self):
|
||||
|
||||
+8
-2
@@ -1230,10 +1230,16 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
|
||||
type = 'warning' if isinstance(exception, Warning) else 'error'
|
||||
# logs the logical (not human-readable) field name for automated
|
||||
# processing of response, but injects human readable in message
|
||||
exc_vals = dict(base, record=record, field=field_names[field])
|
||||
field_name = field_names[field]
|
||||
exc_vals = dict(base, record=record, field=field_name)
|
||||
record = dict(base, type=type, record=record, field=field,
|
||||
message=str(exception.args[0]) % exc_vals)
|
||||
if len(exception.args) > 1 and exception.args[1]:
|
||||
# ensure to add field_name to the exception. Used in import to concatenate multiple errors in the same block
|
||||
if len(exception.args) > 1:
|
||||
if not exception.args[1]:
|
||||
exception.args = (exception.args[0], {'field_name': field_name})
|
||||
else:
|
||||
exception.args[1]['field_name'] = field_name
|
||||
record.update(exception.args[1])
|
||||
log(record)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user