diff options
| author | Xavier Morel <xmo@odoo.com> | 2015-08-26 16:04:14 +0200 |
|---|---|---|
| committer | Xavier Morel <xmo@odoo.com> | 2015-08-26 16:50:09 +0200 |
| commit | f5a1693f064c4a85b7cbc71dc27d72bfcb5e66bf (patch) | |
| tree | 631cb18936b1ca1c0d425fdf5ad4a43f49ac39af | |
| parent | aa6ee3fddfbac63969d216ac2290766a63364006 (diff) | |
[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 <rba@odoo.com>
Mohammed Shekha <msh@openerp.com>
Task 10792
Closes #7285
| -rw-r--r-- | addons/base_import/models.py | 126 | ||||
| -rw-r--r-- | addons/base_import/odf_ods_reader.py | 98 | ||||
| -rw-r--r-- | addons/base_import/static/src/js/import.js | 3 | ||||
| -rw-r--r-- | addons/base_import/static/src/xml/import.xml | 19 | ||||
| -rw-r--r-- | addons/base_import/tests/test.ods | bin | 0 -> 4634 bytes | |||
| -rw-r--r-- | addons/base_import/tests/test.xls | bin | 0 -> 6656 bytes | |||
| -rw-r--r-- | addons/base_import/tests/test.xlsx | bin | 0 -> 6473 bytes | |||
| -rw-r--r-- | addons/base_import/tests/test_cases.py | 133 | ||||
| -rw-r--r-- | addons/base_setup/res_config.py | 2 | ||||
| -rw-r--r-- | openerp/addons/base/ir/ir_fields.py | 7 | ||||
| -rw-r--r-- | openerp/tests/common.py | 16 |
11 files changed, 360 insertions, 44 deletions
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 @@ <input type="hidden" name="session_id" t-att-value="widget.session.session_id"/> <input type="hidden" name="import_id"/> - <p>Select the <a - href="http://en.wikipedia.org/wiki/Comma-separated_values" - target="_blank">.CSV</a> - file to import. If you need a sample importable file, you + <p>Select the file to import. If you need a sample importable file, you can use the export tool to generate one.</p> <div class="oe_import_box"> - <label t-attf-for="file_#{_id}" autofocus="autofocus">CSV File:</label> + <label t-attf-for="file_#{_id}" autofocus="autofocus">File:</label> <input type="file" id-attf-id="file_#{_id}" - name="file" class="oe_import_file" accept=".csv"/> + name="file" class="oe_import_file" accept=".csv, .xls, .xlsx, .ods"/> <button type="button" class="oe_import_file_reload" disabled="disabled" title="Reload data to check changes."> @@ -21,7 +18,7 @@ </button> <div class="oe_import_with_file"> <a href="#" class="oe_import_toggle"> - File Format Options…</a> + CSV Format Options…</a> <div class="oe_import_toggled oe_import_options"> <p t-foreach="widget.opts" t-as="option"> <!-- no @name, avoid submission when file_update called --> @@ -363,11 +360,11 @@ </t> <t t-name="ImportView.preview.error"> <div class="oe_import_report oe_import_report_error"> - <p>Import preview failed due to: <t t-esc="error"/>. The issue is - usually an incorrect file encoding.</p> - <p>Here is the start of the file we could not import:</p> + <p>Import preview failed due to: <t t-esc="error"/>.</p> + <p>For CSV files, the issue could be an incorrect encoding.</p> + <p t-if="preview">Here is the start of the file we could not import:</p> </div> - <pre><t t-esc="preview"/></pre> + <pre t-if="preview"><t t-esc="preview"/></pre> </t> <ul t-name="ImportView.error"> <li t-foreach="errors" t-as="error" diff --git a/addons/base_import/tests/test.ods b/addons/base_import/tests/test.ods Binary files differnew file mode 100644 index 00000000000..b6bfa63be1f --- /dev/null +++ b/addons/base_import/tests/test.ods diff --git a/addons/base_import/tests/test.xls b/addons/base_import/tests/test.xls Binary files differnew file mode 100644 index 00000000000..944f14c20c5 --- /dev/null +++ b/addons/base_import/tests/test.xls diff --git a/addons/base_import/tests/test.xlsx b/addons/base_import/tests/test.xlsx Binary files differnew file mode 100644 index 00000000000..c549c7375f0 --- /dev/null +++ b/addons/base_import/tests/test.xlsx diff --git a/addons/base_import/tests/test_cases.py b/addons/base_import/tests/test_cases.py index 873d749d55a..bd9feafb9ab 100644 --- a/addons/base_import/tests/test_cases.py +++ b/addons/base_import/tests/test_cases.py @@ -1,6 +1,9 @@ # -*- encoding: utf-8 -*- -import unittest2 -from openerp.tests.common import TransactionCase +import unittest + +from openerp.tests.common import TransactionCase, can_import +from openerp.modules.module import get_module_resource + from .. import models @@ -189,6 +192,8 @@ class test_preview(TransactionCase): id = Import.create(self.cr, self.uid, { 'res_model': 'res.users', 'file': u"로그인,언어\nbob,1\n".encode('euc_kr'), + 'file_type': 'text/csv', + 'file_name': 'kr_data.csv', }) return Import, id @@ -217,14 +222,15 @@ class test_preview(TransactionCase): }) self.assertTrue('error' in result) - def test_success(self): + def test_csv_success(self): Import = self.registry('base_import.import') id = Import.create(self.cr, self.uid, { 'res_model': 'base_import.tests.models.preview', 'file': 'name,Some Value,Counter\n' 'foo,1,2\n' 'bar,3,4\n' - 'qux,5,6\n' + 'qux,5,6\n', + 'file_type': 'text/csv' }) result = Import.parse_preview(self.cr, self.uid, id, { @@ -232,7 +238,7 @@ class test_preview(TransactionCase): 'separator': ',', 'headers': True, }) - + self.assertIsNone(result.get('error')) self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None}) self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter']) # Order depends on iteration order of fields_get @@ -250,6 +256,100 @@ class test_preview(TransactionCase): # Ensure we only have the response fields we expect self.assertItemsEqual(result.keys(), ['matches', 'headers', 'fields', 'preview']) + @unittest.skipUnless(can_import('xlrd'), "XLRD module not available") + def test_xls_success(self): + + Import = self.registry('base_import.import') + xls_file_path = get_module_resource('base_import', 'tests', 'test.xls') + file_content = open(xls_file_path, 'rb').read() + id = Import.create(self.cr, self.uid, { + 'res_model': 'base_import.tests.models.preview', + 'file': file_content, + 'file_type': 'application/vnd.ms-excel' + }) + + result = Import.parse_preview(self.cr, self.uid, id, { + 'headers': True, + }) + self.assertIsNone(result.get('error')) + self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None}) + self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter']) + self.assertItemsEqual(result['fields'], [ + ID_FIELD, + {'id': 'name', 'name': 'name', 'string': 'Name', 'required':False, 'fields': []}, + {'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required':True, 'fields': []}, + {'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required':False, 'fields': []}, + ]) + self.assertEqual(result['preview'], [ + ['foo', '1', '2'], + ['bar', '3', '4'], + ['qux', '5', '6'], + ]) + # Ensure we only have the response fields we expect + self.assertItemsEqual(result.keys(), ['matches', 'headers', 'fields', 'preview']) + + @unittest.skipUnless(can_import('xlrd.xlsx'), "XLRD/XLSX not available") + def test_xlsx_success(self): + Import = self.registry('base_import.import') + xlsx_file_path = get_module_resource('base_import', 'tests', 'test.xlsx') + file_content = open(xlsx_file_path, 'rb').read() + id = Import.create(self.cr, self.uid, { + 'res_model': 'base_import.tests.models.preview', + 'file': file_content, + 'file_type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + result = Import.parse_preview(self.cr, self.uid, id, { + 'headers': True, + }) + self.assertIsNone(result.get('error')) + self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None}) + self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter']) + self.assertItemsEqual(result['fields'], [ + ID_FIELD, + {'id': 'name', 'name': 'name', 'string': 'Name', 'required':False, 'fields': []}, + {'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required':True, 'fields': []}, + {'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required':False, 'fields': []}, + ]) + self.assertEqual(result['preview'], [ + ['foo', '1', '2'], + ['bar', '3', '4'], + ['qux', '5', '6'], + ]) + # Ensure we only have the response fields we expect + self.assertItemsEqual(result.keys(), ['matches', 'headers', 'fields', 'preview']) + + @unittest.skipUnless(can_import('odf'), "ODFPY not available") + def test_ods_success(self): + Import = self.registry('base_import.import') + ods_file_path = get_module_resource('base_import', 'tests', 'test.ods') + file_content = open(ods_file_path, 'rb').read() + id = Import.create(self.cr, self.uid, { + 'res_model': 'base_import.tests.models.preview', + 'file': file_content, + 'file_type': 'application/vnd.oasis.opendocument.spreadsheet' + }) + + result = Import.parse_preview(self.cr, self.uid, id, { + 'headers': True, + }) + self.assertIsNone(result.get('error')) + self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None}) + self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter']) + self.assertItemsEqual(result['fields'], [ + ID_FIELD, + {'id': 'name', 'name': 'name', 'string': 'Name', 'required':False, 'fields': []}, + {'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required':True, 'fields': []}, + {'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required':False, 'fields': []}, + ]) + self.assertEqual(result['preview'], [ + ['foo', '1', '2'], + ['bar', '3', '4'], + ['aux', '5', '6'], + ]) + # Ensure we only have the response fields we expect + self.assertItemsEqual(result.keys(), ['matches', 'headers', 'fields', 'preview']) + class test_convert_import_data(TransactionCase): """ Tests conversion of base_import.import input into data which can be fed to Model.import_data @@ -261,7 +361,9 @@ class test_convert_import_data(TransactionCase): 'file': 'name,Some Value,Counter\n' 'foo,1,2\n' 'bar,3,4\n' - 'qux,5,6\n' + 'qux,5,6\n', + 'file_type': 'text/csv' + }) record = Import.browse(self.cr, self.uid, id) data, fields = Import._convert_import_data( @@ -285,7 +387,8 @@ class test_convert_import_data(TransactionCase): 'file': 'name,Some Value,Counter\n' 'foo,1,2\n' 'bar,3,4\n' - 'qux,5,6\n' + 'qux,5,6\n', + 'file_type': 'text/csv' }) record = Import.browse(self.cr, self.uid, id) data, fields = Import._convert_import_data( @@ -309,7 +412,8 @@ class test_convert_import_data(TransactionCase): 'file': 'name,Some Value,Counter\n' 'foo,1,2\n' ',3,\n' - ',5,6\n' + ',5,6\n', + 'file_type': 'text/csv' }) record = Import.browse(self.cr, self.uid, id) data, fields = Import._convert_import_data( @@ -331,7 +435,8 @@ class test_convert_import_data(TransactionCase): '\n' 'bar,2\n' ' \n' - '\t \n' + '\t \n', + 'file_type': 'text/csv' }) record = Import.browse(self.cr, self.uid, id) data, fields = Import._convert_import_data( @@ -350,7 +455,9 @@ class test_convert_import_data(TransactionCase): id = Import.create(self.cr, self.uid, { 'res_model': 'base_import.tests.models.preview', 'file': 'name,Some Value,Counter\n' - 'foo,1,2\n' + 'foo,1,2\n', + 'file_type': 'text/csv' + }) record = Import.browse(self.cr, self.uid, id) @@ -366,7 +473,8 @@ class test_convert_import_data(TransactionCase): id = Import.create(self.cr, self.uid, { 'res_model': 'base_import.tests.models.preview', 'file': 'name,Some Value,Counter\n' - 'foo,1,2\n' + 'foo,1,2\n', + 'file_type': 'text/csv' }) record = Import.browse(self.cr, self.uid, id) @@ -397,7 +505,8 @@ class test_failures(TransactionCase): Import = self.env['base_import.import'] imp = Import.create({ 'res_model': 'ir.attachment', - 'file': fout.getvalue() + 'file': fout.getvalue(), + 'file_type': 'text/csv' }) [results] = imp.do( ['name', 'db_datas'], diff --git a/addons/base_setup/res_config.py b/addons/base_setup/res_config.py index 0a137e51d0b..5912569b030 100644 --- a/addons/base_setup/res_config.py +++ b/addons/base_setup/res_config.py @@ -18,7 +18,7 @@ class base_config_settings(osv.osv_memory): 'module_portal': fields.boolean('Activate the customer portal', help="""Give your customers access to their documents."""), 'module_auth_oauth': fields.boolean('Use external authentication providers, sign in with Google...'), - 'module_base_import': fields.boolean("Allow users to import data from CSV files"), + 'module_base_import': fields.boolean("Allow users to import data from CSV/XLS/XLSX/ODS files"), 'module_google_drive': fields.boolean('Attach Google documents to any record', help="""This installs the module google_docs."""), 'module_google_calendar': fields.boolean('Allow the users to synchronize their calendar with Google Calendar', diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 4b1d3bc34a1..488dfc502fc 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -7,7 +7,7 @@ import time import psycopg2 import pytz -from openerp import models, api, _ +from openerp import models, fields, api, _ from openerp.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT, ustr REFERENCING_FIELDS = {None, 'id', '.id'} @@ -189,6 +189,8 @@ class ir_fields_converter(models.AbstractModel): value ) + _str_to_monetary = _str_to_float + @api.model def _str_id(self, model, field, value): return value, [] @@ -231,8 +233,7 @@ class ir_fields_converter(models.AbstractModel): @api.model def _str_to_datetime(self, model, field, value): try: - parsed_value = datetime.datetime.strptime( - value, DEFAULT_SERVER_DATETIME_FORMAT) + parsed_value = fields.Datetime.from_string(value) except ValueError: raise self._format_import_error( ValueError, diff --git a/openerp/tests/common.py b/openerp/tests/common.py index 932c88ae41a..2885f6c0ea0 100644 --- a/openerp/tests/common.py +++ b/openerp/tests/common.py @@ -6,6 +6,7 @@ helpers and classes to write tests. """ import errno import glob +import importlib import json import logging import os @@ -392,3 +393,18 @@ class HttpCase(TransactionCase): phantomtest = os.path.join(os.path.dirname(__file__), 'phantomtest.js') cmd = ['phantomjs', phantomtest, json.dumps(options)] self.phantom_run(cmd, timeout) + +def can_import(module): + """ Checks if <module> 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 |
