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>
153 lines
5.0 KiB
Python
153 lines
5.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Tests for various autodetection magics for CSV imports
|
|
"""
|
|
import codecs
|
|
|
|
from odoo.tests import common
|
|
|
|
|
|
class ImportCase(common.TransactionCase):
|
|
def _make_import(self, contents):
|
|
return self.env['base_import.import'].create({
|
|
'res_model': 'base_import.tests.models.complex',
|
|
'file_name': 'f',
|
|
'file_type': 'text/csv',
|
|
'file': contents,
|
|
})
|
|
|
|
|
|
class TestEncoding(ImportCase):
|
|
"""
|
|
create + parse_preview -> check result options
|
|
"""
|
|
|
|
def _check_text(self, text, encodings, **options):
|
|
options.setdefault('quoting', '"')
|
|
options.setdefault('separator', '\t')
|
|
test_text = "text\tnumber\tdate\tdatetime\n%s\t1.23.45,67\t\t\n" % text
|
|
for encoding in ['utf-8', 'utf-16', 'utf-32', *encodings]:
|
|
if isinstance(encoding, tuple):
|
|
encoding, es = encoding
|
|
else:
|
|
es = [encoding]
|
|
preview = self._make_import(
|
|
test_text.encode(encoding)).parse_preview(dict(options))
|
|
|
|
self.assertIsNone(preview.get('error'))
|
|
guessed = preview['options']['encoding']
|
|
self.assertIsNotNone(guessed)
|
|
self.assertIn(
|
|
codecs.lookup(guessed).name, [
|
|
codecs.lookup(e).name
|
|
for e in es
|
|
]
|
|
)
|
|
|
|
def test_autodetect_encoding(self):
|
|
""" Check that import preview can detect & return encoding
|
|
"""
|
|
self._check_text("Iñtërnâtiônàlizætiøn", [('iso-8859-1', ['iso-8859-1', 'iso-8859-2'])])
|
|
|
|
self._check_text("やぶら小路の藪柑子。海砂利水魚の、食う寝る処に住む処、パイポパイポ パイポのシューリンガン。", ['eucjp', 'shift_jis', 'iso2022_jp'])
|
|
|
|
self._check_text("대통령은 제4항과 제5항의 규정에 의하여 확정된 법률을 지체없이 공포하여야 한다, 탄핵의 결정.", ['euc_kr', 'iso2022_kr'])
|
|
|
|
# + control in widget
|
|
def test_override_detection(self):
|
|
""" ensure an explicitly specified encoding is not overridden by the
|
|
auto-detection
|
|
"""
|
|
s = "Iñtërnâtiônàlizætiøn".encode('utf-8')
|
|
r = self._make_import(s + b'\ntext')\
|
|
.parse_preview({
|
|
'quoting': '"',
|
|
'separator': '\t',
|
|
'encoding': 'iso-8859-1',
|
|
})
|
|
self.assertIsNone(r.get('error'))
|
|
self.assertEqual(r['options']['encoding'], 'iso-8859-1')
|
|
self.assertEqual(r['preview'], [s.decode('iso-8859-1')])
|
|
|
|
|
|
class TestFileSeparator(ImportCase):
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.imp = self._make_import(
|
|
"""c|f
|
|
a|1
|
|
b|2
|
|
c|3
|
|
d|4
|
|
""")
|
|
|
|
def test_explicit_success(self):
|
|
r = self.imp.parse_preview({
|
|
'separator': '|',
|
|
'has_headers': True,
|
|
'quoting': '"',
|
|
})
|
|
self.assertIsNone(r.get('error'))
|
|
self.assertEqual(r['headers'], ['c', 'f'])
|
|
self.assertEqual(r['preview'], ['a', '1'])
|
|
self.assertEqual(r['options']['separator'], '|')
|
|
|
|
def test_explicit_fail(self):
|
|
""" Don't protect user against making mistakes
|
|
"""
|
|
r = self.imp.parse_preview({
|
|
'separator': ',',
|
|
'has_headers': True,
|
|
'quoting': '"',
|
|
})
|
|
self.assertIsNone(r.get('error'))
|
|
self.assertEqual(r['headers'], ['c|f'])
|
|
self.assertEqual(r['preview'], ['a|1'])
|
|
self.assertEqual(r['options']['separator'], ',')
|
|
|
|
def test_guess_ok(self):
|
|
r = self.imp.parse_preview({
|
|
'separator': '',
|
|
'has_headers': True,
|
|
'quoting': '"',
|
|
})
|
|
self.assertIsNone(r.get('error'))
|
|
self.assertEqual(r['headers'], ['c', 'f'])
|
|
self.assertEqual(r['preview'], ['a', '1'])
|
|
self.assertEqual(r['options']['separator'], '|')
|
|
|
|
def test_noguess(self):
|
|
""" If the guesser has no idea what the separator is, it defaults to
|
|
"," but should not set that value
|
|
"""
|
|
imp = self._make_import('c\na\nb\nc\nd')
|
|
r = imp.parse_preview({
|
|
'separator': '',
|
|
'has_headers': True,
|
|
'quoting': '"',
|
|
})
|
|
self.assertIsNone(r.get('error'))
|
|
self.assertEqual(r['headers'], ['c'])
|
|
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({
|
|
'res_model': 'base_import.tests.models.float',
|
|
})
|
|
data = w._parse_import_data(
|
|
[
|
|
['1.62'], ['-1.62'], ['+1.62'], [' +1.62 '], ['(1.62)'],
|
|
["1'234'567,89"], ["1.234.567'89"]
|
|
],
|
|
['value'], {}
|
|
)
|
|
self.assertEqual(
|
|
[d[0] for d in data],
|
|
['1.62', '-1.62', '+1.62', '+1.62', '-1.62',
|
|
'1234567.89', '1234567.89']
|
|
)
|