summaryrefslogtreecommitdiff
path: root/addons/base_import/models
diff options
context:
space:
mode:
authorHitesh Trivedi <htr@openerp.com>2015-09-24 18:58:58 +0530
committerJérome Maes <jem@openerp.com>2016-07-07 14:44:10 +0200
commitcf3e8324f1f4119347ec685abc5cced3a1cf02a9 (patch)
tree6158813f26f9e633895e59f81fff505f70381416 /addons/base_import/models
parent45b3e6b96c4df1ee8f8532ef2306908e5c27360d (diff)
[MOV] base_import: module directory organization
Note: move test models file in models to not import tests directory, which breaks the mute_logger of test cases
Diffstat (limited to 'addons/base_import/models')
-rw-r--r--addons/base_import/models/__init__.py5
-rw-r--r--addons/base_import/models/base_import.py655
-rw-r--r--addons/base_import/models/odf_ods_reader.py98
-rw-r--r--addons/base_import/models/test_models.py101
4 files changed, 859 insertions, 0 deletions
diff --git a/addons/base_import/models/__init__.py b/addons/base_import/models/__init__.py
new file mode 100644
index 00000000000..0c98d751b68
--- /dev/null
+++ b/addons/base_import/models/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base_import
+import test_models
diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py
new file mode 100644
index 00000000000..36cd41fa7f0
--- /dev/null
+++ b/addons/base_import/models/base_import.py
@@ -0,0 +1,655 @@
+import csv
+import datetime
+import io
+import itertools
+import logging
+import operator
+import os
+import re
+
+from openerp.tools.mimetypes import guess_mimetype
+
+try:
+ from cStringIO import StringIO
+except ImportError:
+ from StringIO import StringIO
+
+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
+ _transient_max_hours = 12.0
+
+ _columns = {
+ 'res_model': fields.char('Model'),
+ '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(string='File Type'),
+ }
+
+ def get_fields(self, cr, uid, model, context=None,
+ depth=FIELDS_RECURSION_LIMIT):
+ """ Recursively get fields for the provided model (through
+ fields_get) and filter them according to importability
+
+ The output format is a list of ``Field``, with ``Field``
+ defined as:
+
+ .. class:: Field
+
+ .. attribute:: id (str)
+
+ A non-unique identifier for the field, used to compute
+ the span of the ``required`` attribute: if multiple
+ ``required`` fields have the same id, only one of them
+ is necessary.
+
+ .. attribute:: name (str)
+
+ The field's logical (Odoo) name within the scope of
+ its parent.
+
+ .. attribute:: string (str)
+
+ The field's human-readable name (``@string``)
+
+ .. attribute:: required (bool)
+
+ Whether the field is marked as required in the
+ model. Clients must provide non-empty import values
+ for all required fields or the import will error out.
+
+ .. attribute:: fields (list(Field))
+
+ The current field's subfields. The database and
+ external identifiers for m2o and m2m fields; a
+ filtered and transformed fields_get for o2m fields (to
+ a variable depth defined by ``depth``).
+
+ Fields with no sub-fields will have an empty list of
+ sub-fields.
+
+ :param str model: name of the model to get fields form
+ :param int landing: depth of recursion into o2m fields
+ """
+ model_obj = self.pool[model]
+ fields = [{
+ 'id': 'id',
+ 'name': 'id',
+ 'string': _("External ID"),
+ 'required': False,
+ 'fields': [],
+ 'type': 'id',
+ }]
+ fields_got = model_obj.fields_get(cr, uid, context=context)
+ blacklist = orm.MAGIC_COLUMNS + [model_obj.CONCURRENCY_CHECK_FIELD]
+ for name, field in fields_got.iteritems():
+ if name in blacklist:
+ continue
+ # an empty string means the field is deprecated, @deprecated must
+ # be absent or False to mean not-deprecated
+ if field.get('deprecated', False) is not False:
+ continue
+ if field.get('readonly'):
+ states = field.get('states')
+ if not states:
+ continue
+ # states = {state: [(attr, value), (attr2, value2)], state2:...}
+ if not any(attr == 'readonly' and value is False
+ for attr, value in itertools.chain.from_iterable(
+ states.itervalues())):
+ continue
+ f = {
+ 'id': name,
+ 'name': name,
+ 'string': field['string'],
+ # Y U NO ALWAYS HAS REQUIRED
+ 'required': bool(field.get('required')),
+ 'fields': [],
+ 'type': field['type'],
+ }
+
+ if field['type'] in ('many2many', 'many2one'):
+ f['fields'] = [
+ dict(f, name='id', string=_("External ID"), type='id'),
+ dict(f, name='.id', string=_("Database ID"), type='id'),
+ ]
+ elif field['type'] == 'one2many' and depth:
+ f['fields'] = self.get_fields(
+ cr, uid, field['relation'], context=context, depth=depth-1)
+ if self.user_has_groups(cr, uid, 'base.group_no_one'):
+ f['fields'].append({'id' : '.id', 'name': '.id', 'string': _("Database ID"), 'required': False, 'fields': [], 'type': 'id'})
+
+ fields.append(f)
+
+ # TODO: cache on model?
+ return fields
+
+ def _read_file(self, file_type, record, options):
+ # guess mimetype from file content
+ mimetype = guess_mimetype(record.file)
+ (file_extension, handler, req) = FILE_TYPE_DICT.get(mimetype, (None, None, None))
+ if handler:
+ try:
+ return getattr(self, '_read_' + file_extension)(record, options)
+ except Exception:
+ _logger.warn("Failed to read file '%s' (transient id %d) using guessed mimetype %s",
+ record.file_name or '<unknown>', record.id, mimetype)
+
+ # try reading with user-provided mimetype
+ (file_extension, handler, req) = FILE_TYPE_DICT.get(file_type, (None, None, None))
+ if handler:
+ try:
+ return getattr(self, '_read_' + file_extension)(record, options)
+ except Exception:
+ _logger.warn("Failed to read file '%s' (transient id %d) using user-provided mimetype %s",
+ record.file_name or '<unknown>', record.id, file_type)
+
+ # 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 in EXTENSIONS:
+ try:
+ return getattr(self, '_read_' + ext[1:])(record, options)
+ except Exception:
+ _logger.warn("Failed to read file '%s' (transient id %s) using file extension",
+ record.file_name, record.id)
+
+ 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)
+ return self._read_xls_book(book)
+ def _read_xls_book(self, book):
+ 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
+
+ :throws csv.Error: if an error is detected during CSV parsing
+ :throws UnicodeDecodeError: if ``options.encoding`` is incorrect
+ """
+ csv_data = record.file
+
+ # TODO: guess encoding with chardet? Or https://github.com/aadsm/jschardet
+ encoding = options.get('encoding', 'utf-8')
+ if encoding != 'utf-8':
+ # csv module expect utf-8, see http://docs.python.org/2/library/csv.html
+ csv_data = csv_data.decode(encoding).encode('utf-8')
+
+ csv_iterator = csv.reader(
+ StringIO(csv_data),
+ quotechar=str(options['quoting']),
+ delimiter=str(options['separator']))
+
+ return (
+ [item.decode('utf-8') for item in row]
+ for row in csv_iterator
+ if any(x for x in row if x.strip())
+ )
+
+ def _try_match_column(self, cr, uid, index, preview_values, fields, options):
+ # If all values are empty in preview than can be any field
+ if all([v == '' for v in preview_values]):
+ return ['all']
+ # If all values starts with __export__ this is probably an id
+ if all(v.startswith('__export__') for v in preview_values):
+ return ['id', 'many2many', 'many2one', 'one2many']
+ # If all values can be cast to int type is either id, float or monetary
+ # Exception: if we only have 1 and 0, it can also be a boolean
+ try:
+ field_type = ['id', 'integer', 'float', 'monetary', 'many2one', 'many2many', 'one2many']
+ res = set(int(v) for v in preview_values)
+ if {0, 1}.issuperset(res):
+ field_type.append('boolean')
+ return field_type
+ except ValueError:
+ pass
+ # If all values are either True or False, type is boolean
+ if all(val.lower() in ('true','false','t','f','') for val in preview_values):
+ return ['boolean']
+ # If all values can be cast to float, type is either float or monetary
+ try:
+ thousand_separator = decimal_separator = False
+ for val in preview_values:
+ if val == '':
+ continue
+ # value might have the currency symbol left or right from the value
+ val = self._remove_currency_symbol(cr, uid, val)
+ if val:
+ if options.get('float_thousand_separator') and options.get('float_decimal_separator'):
+ val = val.replace(options['float_thousand_separator'], '').replace(options['float_decimal_separator'], '.')
+ # We are now sure that this is a float, but we still need to find the
+ # thousand and decimal separator
+ else:
+ if val.count('.') > 1:
+ options['float_thousand_separator'] = '.'
+ options['float_decimal_separator'] = ','
+ elif val.count(',') > 1:
+ options['float_thousand_separator'] = ','
+ options['float_decimal_separator'] = '.'
+ elif val.find('.') > val.find(','):
+ thousand_separator = ','
+ decimal_separator = '.'
+ elif val.find(',') > val.find('.'):
+ thousand_separator = '.'
+ decimal_separator = ','
+ else:
+ # This is not a float so exit this try
+ float('a')
+ if thousand_separator and not options.get('float_decimal_separator'):
+ options['float_thousand_separator'] = thousand_separator
+ options['float_decimal_separator'] = decimal_separator
+ return ['float', 'monetary']
+ except ValueError:
+ pass
+ # Try to see if all values are a date or datetime
+ dt = datetime.datetime
+ separator = [' ','/','-']
+ date_format = ['%mr%dr%Y', '%dr%mr%Y', '%Yr%mr%d', '%Yr%dr%m']
+ date_patterns = [options['date_format']] if options.get('date_format') else []
+ if not date_patterns:
+ date_patterns = [pattern.replace('r', sep) for sep in separator for pattern in date_format]
+ date_patterns.extend([p.replace('Y','y') for p in date_patterns])
+ current_date_pattern = False
+ for date_pattern in date_patterns:
+ date_ok = True
+ datetime_ok = False
+ for val in preview_values:
+ if val == '':
+ continue
+ try:
+ dt.strptime(val, date_pattern)
+ except ValueError:
+ date_ok = False
+ try:
+ dt.strptime(val, date_pattern+' %H:%M:%S')
+ datetime_ok = True
+ except ValueError:
+ datetime_ok = False
+ break
+ if date_ok or datetime_ok:
+ current_date_pattern = date_pattern
+ break
+ if current_date_pattern:
+ options['date_format'] = current_date_pattern
+ return ['date'] if date_ok else ['datetime']
+
+ return ['text', 'char', 'datetime', 'selection', 'many2one', 'one2many', 'many2many', 'html']
+
+ def _find_type_from_preview(self, cr, uid, fields, options, preview):
+ type_fields = []
+ if preview:
+ for column in range(0,len(preview[0])):
+ preview_values = [value[column].strip() for value in preview]
+ type_field = self._try_match_column(cr, uid, column, preview_values, fields, options)
+ type_fields.append(type_field)
+ return type_fields
+
+
+ def _match_header(self, header, fields, options):
+ """ Attempts to match a given header to a field of the
+ imported model.
+
+ :param str header: header name from the CSV file
+ :param fields:
+ :param dict options:
+ :returns: an empty list if the header couldn't be matched, or
+ all the fields to traverse
+ :rtype: list(Field)
+ """
+ string_match = None
+ for field in fields:
+ # FIXME: should match all translations & original
+ # TODO: use string distance (levenshtein? hamming?)
+ if header.lower() == field['name'].lower():
+ return [field]
+ if header.lower() == field['string'].lower():
+ # matching string are not reliable way because
+ # strings have no unique constraint
+ string_match = field
+ if string_match:
+ # this behavior is only applied if there is no matching field['name']
+ return [string_match]
+
+ if '/' not in header:
+ return []
+
+ # relational field path
+ traversal = []
+ subfields = fields
+ # Iteratively dive into fields tree
+ for section in header.split('/'):
+ # Strip section in case spaces are added around '/' for
+ # readability of paths
+ match = self._match_header(section.strip(), subfields, options)
+ # Any match failure, exit
+ if not match: return []
+ # prep subfields for next iteration within match[0]
+ field = match[0]
+ subfields = field['fields']
+ traversal.append(field)
+ return traversal
+
+ def _match_headers(self, rows, fields, options):
+ """ Attempts to match the imported model's fields to the
+ titles of the parsed CSV file, if the file is supposed to have
+ headers.
+
+ Will consume the first line of the ``rows`` iterator.
+
+ Returns a pair of (None, None) if headers were not requested
+ or the list of headers and a dict mapping cell indices
+ to key paths in the ``fields`` tree
+
+ :param Iterator rows:
+ :param dict fields:
+ :param dict options:
+ :rtype: (None, None) | (list(str), dict(int: list(str)))
+ """
+ if not options.get('headers'):
+ return None, None
+
+ headers = next(rows)
+ 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
+ fields-matching between the import's file data and the model's
+ columns.
+
+ If the headers are not requested (not options.headers),
+ ``matches`` and ``headers`` are both ``False``.
+
+ :param id: identifier of the import
+ :param int count: number of preview lines to generate
+ :param options: format-specific options.
+ CSV: {encoding, quoting, separator, headers}
+ :type options: {str, str, str, bool}
+ :returns: {fields, matches, headers, preview} | {error, preview}
+ :rtype: {dict(str: dict(...)), dict(int, list(str)), list(str), list(list(str))} | {str, str}
+ """
+
+ (record,) = self.browse(cr, uid, [id], context=context)
+ fields = self.get_fields(cr, uid, record.res_model, context=context)
+ try:
+ 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
+ # the ``count`` next rows for preview
+ preview = list(itertools.islice(rows, count))
+ assert preview, "CSV file seems to have no content"
+ header_types = self._find_type_from_preview(cr, uid, fields, options, preview)
+ if options.get('keep_matches', False) and len(options.get('fields', [])):
+ matches = {}
+ for index, match in enumerate(options.get('fields')):
+ if match:
+ matches[index] = match.split('/')
+
+ return {
+ 'fields': fields,
+ 'matches': matches or False,
+ 'headers': headers or False,
+ 'headers_type': header_types or False,
+ 'preview': preview,
+ 'options': options,
+ 'debug': self.user_has_groups(cr, uid, 'base.group_no_one'),
+ }
+ except Exception, e:
+ # 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 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': preview,
+ }
+
+ def _convert_import_data(self, record, fields, options, context=None):
+ """ Extracts the input browse_record and fields list (with
+ ``False``-y placeholders for fields to *not* import) into a
+ format Model.load can use: a fields list without holes
+ and the precisely matching data matrix
+
+ :param browse_record record:
+ :param list(str|bool): fields
+ :returns: (data, fields)
+ :rtype: (list(list(str)), list(str))
+ :raises ValueError: in case the import data could not be converted
+ """
+ # Get indices for non-empty fields
+ indices = [index for index, field in enumerate(fields) if field]
+ if not indices:
+ raise ValueError(_("You must configure at least one field to import"))
+ # If only one index, itemgetter will return an atom rather
+ # than a 1-tuple
+ if len(indices) == 1: mapper = lambda row: [row[indices[0]]]
+ else: mapper = operator.itemgetter(*indices)
+ # Get only list of actually imported fields
+ import_fields = filter(None, fields)
+
+ 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)
+ data = [
+ list(row) for row in itertools.imap(mapper, rows_to_import)
+ # don't try inserting completely empty rows (e.g. from
+ # filtering out o2m fields)
+ if any(row)
+ ]
+
+ return data, import_fields
+
+ def _remove_currency_symbol(self, cr, uid, value):
+ value = value.strip()
+ negative = False
+ # Careful that some countries use () for negative so replace it by - sign
+ if value.startswith('(') and value.endswith(')'):
+ value = value[1:-1]
+ negative = True
+ float_regex = re.compile(r'([-]?[0-9.,]+)')
+ split_value = filter(None, float_regex.split(value))
+ if len(split_value) > 2:
+ # This is probably not a float
+ return False
+ if len(split_value) == 1:
+ if float_regex.search(split_value[0]) is not None:
+ return split_value[0] if not negative else '-'+split_value[0]
+ return False
+ else:
+ # String has been split in 2, locate which index contains the float and which does not
+ currency_index = 0
+ if float_regex.search(split_value[0]) is not None:
+ currency_index = 1
+ # Check that currency exists
+ currency = self.pool.get('res.currency').search(cr, uid, [('symbol', '=', split_value[currency_index].strip())])
+ if len(currency):
+ return split_value[(currency_index+1)%2] if not negative else '-'+split_value[(currency_index+1)%2]
+ # Otherwise it is not a float with a currency symbol
+ return False
+
+ def _parse_float_from_data(self, cr, uid, data, index, name, options, context=None):
+ thousand_separator = options.get('float_thousand_separator', ' ')
+ decimal_separator = options.get('float_decimal_separator', '.')
+ for line in data:
+ if not line[index]:
+ continue
+ line[index] = line[index].replace(thousand_separator, '').replace(decimal_separator, '.')
+ old_value = line[index]
+ line[index] = self._remove_currency_symbol(cr, uid, line[index])
+ if line[index] == False:
+ raise ValueError(_("Column %s contains incorrect values (value: %s)" % (name, old_value)))
+
+ def _parse_import_data(self, cr, uid, data, import_fields, record, options, context=None):
+ # Get fields of type date/datetime
+ all_fields = self.pool[record.res_model].fields_get(cr, uid, context=context)
+ for name, field in all_fields.iteritems():
+ if field['type'] in ('date', 'datetime') and name in import_fields:
+ # Parse date
+ index = import_fields.index(name)
+ dt = datetime.datetime
+ field_date_format = DEFAULT_SERVER_DATE_FORMAT if field['type'] == 'date' else DEFAULT_SERVER_DATETIME_FORMAT
+ if options.get('date_format', field_date_format) != field_date_format:
+ for line in data:
+ if line[index]:
+ try:
+ line[index] = dt.strftime(dt.strptime(line[index], options['date_format']), field_date_format)
+ except ValueError:
+ raise ValueError(_("Column %s contains incorrect values (value: %s does not match date format" % (name, line[index])))
+ elif field['type'] in ('float', 'monetary') and name in import_fields:
+ # Parse float, sometimes float values from file have currency symbol or () to denote a negative value
+ # We should be able to manage both case
+ index = import_fields.index(name)
+ self._parse_float_from_data(cr, uid, data, index, name, options, context=context)
+ return data
+
+ def do(self, cr, uid, id, fields, options, dryrun=False, context=None):
+ """ Actual execution of the import
+
+ :param fields: import mapping: maps each column to a field,
+ ``False`` for the columns to ignore
+ :type fields: list(str|bool)
+ :param dict options:
+ :param bool dryrun: performs all import operations (and
+ validations) but rollbacks writes, allows
+ getting as much errors as possible without
+ the risk of clobbering the database.
+ :returns: A list of errors. If the list is empty the import
+ executed fully and correctly. If the list is
+ non-empty it contains dicts with 3 keys ``type`` the
+ type of error (``error|warning``); ``message`` the
+ error message associated with the error (a string)
+ and ``record`` the data which failed to import (or
+ ``false`` if that data isn't available or provided)
+ :rtype: list({type, message, record})
+ """
+ cr.execute('SAVEPOINT import')
+
+ (record,) = self.browse(cr, uid, [id], context=context)
+ try:
+ data, import_fields = self._convert_import_data(
+ record, fields, options, context=context)
+ # Parse date and float field
+ data = self._parse_import_data(cr, uid, data, import_fields, record, options, context=context)
+ except ValueError, e:
+ return [{
+ 'type': 'error',
+ 'message': unicode(e),
+ 'record': False,
+ }]
+
+ _logger.info('importing %d rows...', len(data))
+ import_result = self.pool[record.res_model].load(
+ cr, uid, import_fields, data, context=context)
+ _logger.info('done')
+
+ # If transaction aborted, RELEASE SAVEPOINT is going to raise
+ # an InternalError (ROLLBACK should work, maybe). Ignore that.
+ # TODO: to handle multiple errors, create savepoint around
+ # write and release it in case of write error (after
+ # adding error to errors array) => can keep on trying to
+ # import stuff, and rollback at the end if there is any
+ # error in the results.
+ try:
+ if dryrun:
+ cr.execute('ROLLBACK TO SAVEPOINT import')
+ else:
+ cr.execute('RELEASE SAVEPOINT import')
+ except psycopg2.InternalError:
+ pass
+
+ return import_result['messages']
diff --git a/addons/base_import/models/odf_ods_reader.py b/addons/base_import/models/odf_ods_reader.py
new file mode 100644
index 00000000000..0f0142f3f04
--- /dev/null
+++ b/addons/base_import/models/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/models/test_models.py b/addons/base_import/models/test_models.py
new file mode 100644
index 00000000000..274f7c84d5f
--- /dev/null
+++ b/addons/base_import/models/test_models.py
@@ -0,0 +1,101 @@
+from openerp.osv import orm, fields
+
+def name(n): return 'base_import.tests.models.%s' % n
+
+class char(orm.Model):
+ _name = name('char')
+
+ _columns = {
+ 'value': fields.char('unknown')
+ }
+
+class char_required(orm.Model):
+ _name = name('char.required')
+
+ _columns = {
+ 'value': fields.char('unknown', required=True)
+ }
+
+class char_readonly(orm.Model):
+ _name = name('char.readonly')
+
+ _columns = {
+ 'value': fields.char('unknown', readonly=True)
+ }
+
+class char_states(orm.Model):
+ _name = name('char.states')
+
+ _columns = {
+ 'value': fields.char('unknown', readonly=True, states={'draft': [('readonly', False)]})
+ }
+
+class char_noreadonly(orm.Model):
+ _name = name('char.noreadonly')
+
+ _columns = {
+ 'value': fields.char('unknown', readonly=True, states={'draft': [('invisible', True)]})
+ }
+
+class char_stillreadonly(orm.Model):
+ _name = name('char.stillreadonly')
+
+ _columns = {
+ 'value': fields.char('unknown', readonly=True, states={'draft': [('readonly', True)]})
+ }
+
+# TODO: complex field (m2m, o2m, m2o)
+class m2o(orm.Model):
+ _name = name('m2o')
+
+ _columns = {
+ 'value': fields.many2one(name('m2o.related'))
+ }
+class m2o_related(orm.Model):
+ _name = name('m2o.related')
+
+ _columns = {
+ 'value': fields.integer()
+ }
+ _defaults = {
+ 'value': 42
+ }
+
+class m2o_required(orm.Model):
+ _name = name('m2o.required')
+
+ _columns = {
+ 'value': fields.many2one(name('m2o.required.related'), required=True)
+ }
+class m2o_required_related(orm.Model):
+ _name = name('m2o.required.related')
+
+ _columns = {
+ 'value': fields.integer()
+ }
+ _defaults = {
+ 'value': 42
+ }
+
+class o2m(orm.Model):
+ _name = name('o2m')
+
+ _columns = {
+ 'value': fields.one2many(name('o2m.child'), 'parent_id')
+ }
+class o2m_child(orm.Model):
+ _name = name('o2m.child')
+
+ _columns = {
+ 'parent_id': fields.many2one(name('o2m')),
+ 'value': fields.integer()
+ }
+
+class preview_model(orm.Model):
+ _name = name('preview')
+
+ _columns = {
+ 'name': fields.char('Name'),
+ 'somevalue': fields.integer('Some Value', required=True),
+ 'othervalue': fields.integer('Other Variable'),
+ }