From f5a1693f064c4a85b7cbc71dc27d72bfcb5e66bf Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Wed, 26 Aug 2015 16:04:14 +0200 Subject: [PATCH] [ADD] base_import: support for XLS and ODS files * added intermediate ``_read_file`` step dispatching between CSV, ODS and XLS(X) parsing - primary dispatch on mime type, secondary on file extension: + OS may not provide a relevant mime type if no software locally installed for the filetype (e.g. Windows sends excel files as application/octet-stream if excel is not installed, and ODS files as zip if OpenOffice/LibreOffice isn't installed) + applications may re-register extensions to non-standard mimetypes breaking the dispatcher So if the mimetype is found trust it, otherwise try with the filename's extension (if any) - all readers skip lines with only empty cells in their output - ODS and XLS content are "CSVified", rows are converted to arrays of unicode strings * UI altered to not assume CSV files everywhere, and avoid returning garbage preview data for non-CSV imports Various: * added a ``can_import`` utility function to tests.common, can be used to skip tests if an optional Python dependency is not installed (but one would like tests to run if the dependency is available) * fixed datetime issue in ir_fields * added converter for monetary fields (iso float) * spreadsheet don't have integers, twiddling required to ensure integral values won't be serialized as floats (breaking conversion back to Python) * improved some tests by asserting no error is generated (bonus: logs the error message if there is one, rather than just saying the result is blown) * because some systems only provide elderly versions of XLRD (e.g. current debian stable provides 0.9.2 from April 2013) - keep using xldate_as_tuple instead of 0.9.3's xldate_as_datetime, workaround is simple - check for xlrd.xlsx in case of pre-0.8 XLRD Authorship: Ronak Baxi Mohammed Shekha Task 10792 Closes #7285 --- addons/base_import/models.py | 126 +++++++++++++++--- addons/base_import/odf_ods_reader.py | 98 ++++++++++++++ addons/base_import/static/src/js/import.js | 3 +- addons/base_import/static/src/xml/import.xml | 19 ++- addons/base_import/tests/test.ods | Bin 0 -> 4634 bytes addons/base_import/tests/test.xls | Bin 0 -> 6656 bytes addons/base_import/tests/test.xlsx | Bin 0 -> 6473 bytes addons/base_import/tests/test_cases.py | 133 +++++++++++++++++-- addons/base_setup/res_config.py | 2 +- openerp/addons/base/ir/ir_fields.py | 7 +- openerp/tests/common.py | 16 +++ 11 files changed, 360 insertions(+), 44 deletions(-) create mode 100644 addons/base_import/odf_ods_reader.py create mode 100644 addons/base_import/tests/test.ods create mode 100644 addons/base_import/tests/test.xls create mode 100644 addons/base_import/tests/test.xlsx diff --git a/addons/base_import/models.py b/addons/base_import/models.py index 784ae7f49f1..7463a02028e 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -1,7 +1,10 @@ import csv +import datetime +import io import itertools import logging import operator +import os try: from cStringIO import StringIO @@ -12,10 +15,38 @@ import psycopg2 from openerp.osv import orm, fields from openerp.tools.translate import _ +from openerp.tools import DEFAULT_SERVER_DATE_FORMAT, \ + DEFAULT_SERVER_DATETIME_FORMAT FIELDS_RECURSION_LIMIT = 2 ERROR_PREVIEW_BYTES = 200 _logger = logging.getLogger(__name__) + +try: + import xlrd + try: + from xlrd import xlsx + except ImportError: + xlsx = None +except ImportError: + xlrd = xlsx = None + +try: + import odf_ods_reader +except ImportError: + odf_ods_reader = None + +FILE_TYPE_DICT = { + 'text/csv': ('csv', True, None), + 'application/vnd.ms-excel': ('xls', xlrd, 'xlrd'), + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ('xlsx', xlsx, 'xlrd >= 0.8'), + 'application/vnd.oasis.opendocument.spreadsheet': ('ods', odf_ods_reader, 'odfpy') +} +EXTENSIONS = { + '.' + ext: handler + for mime, (ext, handler, req) in FILE_TYPE_DICT.iteritems() +} + class ir_import(orm.TransientModel): _name = 'base_import.import' # allow imports to survive for 12h in case user is slow @@ -26,7 +57,7 @@ class ir_import(orm.TransientModel): 'file': fields.binary( 'File', help="File to check and/or import, raw binary (not base64)"), 'file_name': fields.char('File Name'), - 'file_type': fields.char('File Type'), + 'file_type': fields.char(string='File Type'), } def get_fields(self, cr, uid, model, context=None, @@ -126,6 +157,69 @@ class ir_import(orm.TransientModel): # TODO: cache on model? return fields + def _read_file(self, file_type, record, options): + (file_extension, handler, req) = FILE_TYPE_DICT.get(file_type, (None, None, None)) + if handler: + return getattr(self, '_read_' + file_extension)(record, options) + # fallback on file extensions as mime types can be unreliable (e.g. + # software setting incorrect mime types, or non-installed software + # leading to browser not sending mime types) + if record.file_name: + p, ext = os.path.splitext(record.file_name) + if ext and EXTENSIONS.get(ext): + return getattr(self, '_read_' + ext[1:])(record, options) + + if req: + raise ImportError(_("Unable to load \"{extension}\" file: requires Python module \"{modname}\"").format(extension=file_extension, modname=req)) + raise ValueError(_("Unsupported file format \"{}\", import only supports CSV, ODS, XLS and XLSX").format(file_type)) + + def _read_xls(self, record, options): + book = xlrd.open_workbook(file_contents=record.file) + sheet = book.sheet_by_index(0) + # emulate Sheet.get_rows for pre-0.9.4 + for row in itertools.imap(sheet.row, range(sheet.nrows)): + values = [] + for cell in row: + if cell.ctype is xlrd.XL_CELL_NUMBER: + is_float = cell.value % 1 != 0.0 + values.append( + unicode(cell.value) + if is_float + else unicode(int(cell.value)) + ) + elif cell.ctype is xlrd.XL_CELL_DATE: + is_datetime = cell.value % 1 != 0.0 + # emulate xldate_as_datetime for pre-0.9.3 + dt = datetime.datetime(*xlrd.xldate.xldate_as_tuple( + cell.value, book.datemode)) + values.append( + dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT) + if is_datetime + else dt.strftime(DEFAULT_SERVER_DATE_FORMAT) + ) + elif cell.ctype is xlrd.XL_CELL_BOOLEAN: + values.append(u'True' if cell.value else u'False') + elif cell.ctype is xlrd.XL_CELL_ERROR: + raise ValueError( + _("Error cell found while reading XLS/XLSX file: %s") % + xlrd.error_text_from_code.get( + cell.value, "unknown error code %s" % cell.value) + ) + else: + values.append(cell.value) + if any(x for x in values if x.strip()): + yield values + _read_xlsx = _read_xls + + def _read_ods(self, record, options): + doc = odf_ods_reader.ODSReader(file=io.BytesIO(record.file)) + + return ( + row + for row in doc.getFirstSheet() + if any(x for x in row if x.strip()) + ) + def _read_csv(self, record, options): """ Returns a CSV-parsed iterator of all empty lines in the file @@ -137,15 +231,13 @@ class ir_import(orm.TransientModel): quotechar=str(options['quoting']), delimiter=str(options['separator'])) - def nonempty(row): - return any(x for x in row if x.strip()) - - csv_nonempty = itertools.ifilter(nonempty, csv_iterator) # TODO: guess encoding with chardet? Or https://github.com/aadsm/jschardet encoding = options.get('encoding', 'utf-8') - return itertools.imap( - lambda row: [item.decode(encoding) for item in row], - csv_nonempty) + return ( + [item.decode(encoding) for item in row] + for row in csv_iterator + if any(x for x in row if x.strip()) + ) def _match_header(self, header, fields, options): """ Attempts to match a given header to a field of the @@ -211,10 +303,10 @@ class ir_import(orm.TransientModel): return None, None headers = next(rows) - return headers, dict( - (index, [field['name'] for field in self._match_header(header, fields, options)] or None) + return headers, { + index: [field['name'] for field in self._match_header(header, fields, options)] or None for index, header in enumerate(headers) - ) + } def parse_preview(self, cr, uid, id, options, count=10, context=None): """ Generates a preview of the uploaded files, and performs @@ -236,7 +328,7 @@ class ir_import(orm.TransientModel): fields = self.get_fields(cr, uid, record.res_model, context=context) try: - rows = self._read_csv(record, options) + rows = self._read_file(record.file_type, record, options) headers, matches = self._match_headers(rows, fields, options) # Match should have consumed the first row (iif headers), get @@ -253,15 +345,17 @@ class ir_import(orm.TransientModel): # Due to lazy generators, UnicodeDecodeError (for # instance) may only be raised when serializing the # preview to a list in the return. - _logger.debug("Error during CSV parsing preview", exc_info=True) + _logger.debug("Error during parsing preview", exc_info=True) + preview = None + if record.file_type == 'text/csv': + preview = record.file[:ERROR_PREVIEW_BYTES].decode('iso-8859-1') return { 'error': str(e), # iso-8859-1 ensures decoding will always succeed, # even if it yields non-printable characters. This is # in case of UnicodeDecodeError (or csv.Error # compounded with UnicodeDecodeError) - 'preview': record.file[:ERROR_PREVIEW_BYTES] - .decode( 'iso-8859-1'), + 'preview': preview, } def _convert_import_data(self, record, fields, options, context=None): @@ -287,7 +381,7 @@ class ir_import(orm.TransientModel): # Get only list of actually imported fields import_fields = filter(None, fields) - rows_to_import = self._read_csv(record, options) + rows_to_import = self._read_file(record.file_type, record, options) if options.get('headers'): rows_to_import = itertools.islice( rows_to_import, 1, None) diff --git a/addons/base_import/odf_ods_reader.py b/addons/base_import/odf_ods_reader.py new file mode 100644 index 00000000000..0f0142f3f04 --- /dev/null +++ b/addons/base_import/odf_ods_reader.py @@ -0,0 +1,98 @@ +# Copyright 2011 Marco Conti + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# sourced from https://github.com/marcoconti83/read-ods-with-odfpy +# further altered locally + +from odf import opendocument +from odf.table import Table, TableRow, TableCell +from odf.text import P + + +class ODSReader(object): + + # loads the file + def __init__(self, file=None, content=None, clonespannedcolumns=None): + if not content: + self.clonespannedcolumns = clonespannedcolumns + self.doc = opendocument.load(file) + else: + self.clonespannedcolumns = clonespannedcolumns + self.doc = content + self.SHEETS = {} + for sheet in self.doc.spreadsheet.getElementsByType(Table): + self.readSheet(sheet) + + # reads a sheet in the sheet dictionary, storing each sheet as an + # array (rows) of arrays (columns) + def readSheet(self, sheet): + name = sheet.getAttribute("name") + rows = sheet.getElementsByType(TableRow) + arrRows = [] + + # for each row + for row in rows: + arrCells = [] + cells = row.getElementsByType(TableCell) + + # for each cell + for count, cell in enumerate(cells, start=1): + # repeated value? + repeat = 0 + if count != len(cells): + repeat = cell.getAttribute("numbercolumnsrepeated") + if not repeat: + repeat = 1 + spanned = int(cell.getAttribute('numbercolumnsspanned') or 0) + # clone spanned cells + if self.clonespannedcolumns is not None and spanned > 1: + repeat = spanned + + ps = cell.getElementsByType(P) + textContent = u"" + + # for each text/text:span node + for p in ps: + for n in p.childNodes: + if n.nodeType == 1 and n.tagName == "text:span": + for c in n.childNodes: + if c.nodeType == 3: + textContent = u'{}{}'.format(textContent, n.data) + + if n.nodeType == 3: + textContent = u'{}{}'.format(textContent, n.data) + + if textContent: + if not textContent.startswith("#"): # ignore comments cells + for rr in range(int(repeat)): # repeated? + arrCells.append(textContent) + else: + for rr in range(int(repeat)): + arrCells.append("") + + # if row contained something + if arrCells: + arrRows.append(arrCells) + + #else: + # print ("Empty or commented row (", row_comment, ")") + + self.SHEETS[name] = arrRows + + # returns a sheet as an array (rows) of arrays (columns) + def getSheet(self, name): + return self.SHEETS[name] + + def getFirstSheet(self): + return next(iter(self.SHEETS.itervalues())) diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index 4854df76410..5c57ff708f5 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -144,7 +144,7 @@ var DataImport = Widget.extend(ControlPanelMixin, { this.id = null; this.Import = new Model('base_import.import'); this.session = session; - action.display_name = _t('Import a CSV File'); // Displayed in the breadcrumbs + action.display_name = _t('Import a File'); // Displayed in the breadcrumbs }, start: function () { var self = this; @@ -237,6 +237,7 @@ var DataImport = Widget.extend(ControlPanelMixin, { if (!this.$('input.oe_import_file').val()) { return; } this.$el.removeClass('oe_import_preview oe_import_error'); + this.$el.find('.oe_import_toggle').toggle((this.$('input.oe_import_file')[0].files[0].type == "text/csv")); jsonp(this.$el, { url: '/base_import/set_file' }, this.proxy('settings_changed')); diff --git a/addons/base_import/static/src/xml/import.xml b/addons/base_import/static/src/xml/import.xml index c3eb2dc2b0d..ae5c78c497d 100644 --- a/addons/base_import/static/src/xml/import.xml +++ b/addons/base_import/static/src/xml/import.xml @@ -5,15 +5,12 @@ -

Select the .CSV - file to import. If you need a sample importable file, you +

Select the file to import. If you need a sample importable file, you can use the export tool to generate one.

- + + name="file" class="oe_import_file" accept=".csv, .xls, .xlsx, .ods"/>
- File Format Options… + CSV Format Options…

@@ -363,11 +360,11 @@

-

Import preview failed due to: . The issue is - usually an incorrect file encoding.

-

Here is the start of the file we could not import:

+

Import preview failed due to: .

+

For CSV files, the issue could be an incorrect encoding.

+

Here is the start of the file we could not import:

-
+
  • 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 literal 0 HcmV?d00001 diff --git a/addons/base_import/tests/test.xls b/addons/base_import/tests/test.xls new file mode 100644 index 0000000000000000000000000000000000000000..944f14c20c50c51c15325d160c3a6772fe18456e GIT binary patch literal 6656 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 can be imported, returns ``True`` if it can be, + ``False`` otherwise. + + To use with ``unittest.skipUnless`` for tests conditional on *optional* + dependencies, which may or may be present but must still be tested if + possible. + """ + try: + importlib.import_module(module) + except ImportError: + return False + else: + return True