[MIG] base_import : migrate to new api

This commit is contained in:
Hitesh Trivedi
2016-07-07 14:44:10 +02:00
committed by Jérome Maes
parent cf3e8324f1
commit 46df9da439
3 changed files with 177 additions and 158 deletions
+12 -8
View File
@@ -1,19 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import cgi
import json
from openerp.http import Controller, route
from odoo import http
from odoo.http import request
class ImportController(Controller):
@route('/base_import/set_file', methods=['POST'])
def set_file(self, req, file, import_id, jsonp='callback'):
class ImportController(http.Controller):
@http.route('/base_import/set_file', methods=['POST'])
def set_file(self, file, import_id, jsonp='callback'):
import_id = int(import_id)
written = req.session.model('base_import.import').write(import_id, {
written = request.env['base_import.import'].browse(import_id).write({
'file': file.read(),
'file_name': file.filename,
'file_type': file.content_type,
}, req.context)
})
return 'window.top.%s(%s)' % (
cgi.escape(jsonp), json.dumps({'result': written}))
return 'window.top.%s(%s)' % (cgi.escape(jsonp), json.dumps({'result': written}))
+164 -149
View File
@@ -1,25 +1,26 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import csv
import datetime
import io
import itertools
import logging
import psycopg2
import operator
import os
import re
from openerp.tools.mimetypes import guess_mimetype
from odoo import api, fields, models
from odoo.tools.translate import _
from odoo.tools.mimetypes import guess_mimetype
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT
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
@@ -50,21 +51,21 @@ EXTENSIONS = {
for mime, (ext, handler, req) in FILE_TYPE_DICT.iteritems()
}
class ir_import(orm.TransientModel):
class Import(models.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'),
}
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('File Type')
def get_fields(self, cr, uid, model, context=None,
depth=FIELDS_RECURSION_LIMIT):
@api.model
def get_fields(self, model, depth=FIELDS_RECURSION_LIMIT):
""" Recursively get fields for the provided model (through
fields_get) and filter them according to importability
@@ -108,8 +109,8 @@ class ir_import(orm.TransientModel):
: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 = [{
Model = self.env[model]
importable_fields = [{
'id': 'id',
'name': 'id',
'string': _("External ID"),
@@ -117,9 +118,9 @@ class ir_import(orm.TransientModel):
'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():
model_fields = Model.fields_get()
blacklist = models.MAGIC_COLUMNS + [Model.CONCURRENCY_CHECK_FIELD]
for name, field in model_fields.iteritems():
if name in blacklist:
continue
# an empty string means the field is deprecated, @deprecated must
@@ -132,10 +133,9 @@ class ir_import(orm.TransientModel):
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())):
for attr, value in itertools.chain.from_iterable(states.itervalues())):
continue
f = {
field_value = {
'id': name,
'name': name,
'string': field['string'],
@@ -146,60 +146,64 @@ class ir_import(orm.TransientModel):
}
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'),
field_value['fields'] = [
dict(field_value, name='id', string=_("External ID"), type='id'),
dict(field_value, 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'})
field_value['fields'] = self.get_fields(field['relation'], depth=depth-1)
if self.user_has_groups('base.group_no_one'):
field_value['fields'].append({'id': '.id', 'name': '.id', 'string': _("Database ID"), 'required': False, 'fields': [], 'type': 'id'})
fields.append(f)
importable_fields.append(field_value)
# TODO: cache on model?
return fields
return importable_fields
def _read_file(self, file_type, record, options):
@api.multi
def _read_file(self, options):
""" Dispatch to specific method to read file content, according to its mimetype or file type
:param options : dict of reading options (quoting, separator, ...)
"""
self.ensure_one()
# guess mimetype from file content
mimetype = guess_mimetype(record.file)
mimetype = guess_mimetype(self.file)
(file_extension, handler, req) = FILE_TYPE_DICT.get(mimetype, (None, None, None))
if handler:
try:
return getattr(self, '_read_' + file_extension)(record, options)
return getattr(self, '_read_' + file_extension)(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)
_logger.warn("Failed to read file '%s' (transient id %d) using guessed mimetype %s", self.file_name or '<unknown>', self.id, mimetype)
# try reading with user-provided mimetype
(file_extension, handler, req) = FILE_TYPE_DICT.get(file_type, (None, None, None))
(file_extension, handler, req) = FILE_TYPE_DICT.get(self.file_type, (None, None, None))
if handler:
try:
return getattr(self, '_read_' + file_extension)(record, options)
return getattr(self, '_read_' + file_extension)(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)
_logger.warn("Failed to read file '%s' (transient id %d) using user-provided mimetype %s", self.file_name or '<unknown>', self.id, self.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 self.file_name:
p, ext = os.path.splitext(self.file_name)
if ext in EXTENSIONS:
try:
return getattr(self, '_read_' + ext[1:])(record, options)
return getattr(self, '_read_' + ext[1:])(options)
except Exception:
_logger.warn("Failed to read file '%s' (transient id %s) using file extension",
record.file_name, record.id)
_logger.warn("Failed to read file '%s' (transient id %s) using file extension", self.file_name, self.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))
raise ValueError(_("Unsupported file format \"{}\", import only supports CSV, ODS, XLS and XLSX").format(self.file_type))
def _read_xls(self, record, options):
book = xlrd.open_workbook(file_contents=record.file)
@api.multi
def _read_xls(self, options):
""" Read file content, using xlrd lib """
book = xlrd.open_workbook(file_contents=self.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
@@ -216,8 +220,7 @@ class ir_import(orm.TransientModel):
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))
dt = datetime.datetime(*xlrd.xldate.xldate_as_tuple(cell.value, book.datemode))
values.append(
dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
if is_datetime
@@ -235,10 +238,14 @@ class ir_import(orm.TransientModel):
values.append(cell.value)
if any(x for x in values if x.strip()):
yield values
# use the same method for xlsx and xls files
_read_xlsx = _read_xls
def _read_ods(self, record, options):
doc = odf_ods_reader.ODSReader(file=io.BytesIO(record.file))
@api.multi
def _read_ods(self, options):
""" Read file content using ODSReader custom lib """
doc = odf_ods_reader.ODSReader(file=io.BytesIO(self.file))
return (
row
@@ -246,13 +253,13 @@ class ir_import(orm.TransientModel):
if any(x for x in row if x.strip())
)
def _read_csv(self, record, options):
@api.multi
def _read_csv(self, 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
:throws csv.Error: if an error is detected during CSV parsing
:throws UnicodeDecodeError: if ``options.encoding`` is incorrect
"""
csv_data = record.file
csv_data = self.file
# TODO: guess encoding with chardet? Or https://github.com/aadsm/jschardet
encoding = options.get('encoding', 'utf-8')
@@ -271,7 +278,12 @@ class ir_import(orm.TransientModel):
if any(x for x in row if x.strip())
)
def _try_match_column(self, cr, uid, index, preview_values, fields, options):
@api.model
def _try_match_column(self, preview_values, options):
""" Returns the potential field types, based on the preview values, using heuristics
:param preview_values : list of value for the column to determine
:param options : parsing options
"""
# If all values are empty in preview than can be any field
if all([v == '' for v in preview_values]):
return ['all']
@@ -289,7 +301,7 @@ class ir_import(orm.TransientModel):
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):
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:
@@ -298,11 +310,11 @@ class ir_import(orm.TransientModel):
if val == '':
continue
# value might have the currency symbol left or right from the value
val = self._remove_currency_symbol(cr, uid, val)
val = self._remove_currency_symbol(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
# 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:
@@ -328,12 +340,12 @@ class ir_import(orm.TransientModel):
pass
# Try to see if all values are a date or datetime
dt = datetime.datetime
separator = [' ','/','-']
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])
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
@@ -360,26 +372,26 @@ class ir_import(orm.TransientModel):
return ['text', 'char', 'datetime', 'selection', 'many2one', 'one2many', 'many2many', 'html']
def _find_type_from_preview(self, cr, uid, fields, options, preview):
@api.model
def _find_type_from_preview(self, options, preview):
type_fields = []
if preview:
for column in range(0,len(preview[0])):
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_field = self._try_match_column(preview_values, 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.
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)
: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:
@@ -407,7 +419,8 @@ class ir_import(orm.TransientModel):
# readability of paths
match = self._match_header(section.strip(), subfields, options)
# Any match failure, exit
if not match: return []
if not match:
return []
# prep subfields for next iteration within match[0]
field = match[0]
subfields = field['fields']
@@ -416,56 +429,55 @@ class ir_import(orm.TransientModel):
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.
titles of the parsed CSV file, if the file is supposed to have
headers.
Will consume the first line of the ``rows`` iterator.
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
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)))
: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, {
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):
@api.multi
def parse_preview(self, options, count=10):
""" Generates a preview of the uploaded files, and performs
fields-matching between the import's file data and the model's
columns.
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``.
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}
: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)
self.ensure_one()
fields = self.get_fields(self.res_model)
try:
rows = self._read_file(record.file_type, record, options)
rows = self._read_file(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)
header_types = self._find_type_from_preview(options, preview)
if options.get('keep_matches', False) and len(options.get('fields', [])):
matches = {}
for index, match in enumerate(options.get('fields')):
@@ -479,18 +491,18 @@ class ir_import(orm.TransientModel):
'headers_type': header_types or False,
'preview': preview,
'options': options,
'debug': self.user_has_groups(cr, uid, 'base.group_no_one'),
'debug': self.user_has_groups('base.group_no_one'),
}
except Exception, e:
except Exception, error:
# 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')
if self.file_type == 'text/csv':
preview = self.file[:ERROR_PREVIEW_BYTES].decode('iso-8859-1')
return {
'error': str(e),
'error': str(error),
# iso-8859-1 ensures decoding will always succeed,
# even if it yields non-printable characters. This is
# in case of UnicodeDecodeError (or csv.Error
@@ -498,17 +510,17 @@ class ir_import(orm.TransientModel):
'preview': preview,
}
def _convert_import_data(self, record, fields, options, context=None):
@api.model
def _convert_import_data(self, fields, options):
""" 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
``False``-y placeholders for fields to *not* import) into a
format Model.import_data 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
: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]
@@ -516,15 +528,16 @@ class ir_import(orm.TransientModel):
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)
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)
rows_to_import = self._read_file(options)
if options.get('headers'):
rows_to_import = itertools.islice(
rows_to_import, 1, None)
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
@@ -534,7 +547,8 @@ class ir_import(orm.TransientModel):
return data, import_fields
def _remove_currency_symbol(self, cr, uid, value):
@api.model
def _remove_currency_symbol(self, value):
value = value.strip()
negative = False
# Careful that some countries use () for negative so replace it by - sign
@@ -548,21 +562,22 @@ class ir_import(orm.TransientModel):
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
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())])
currency = self.env['res.currency'].search([('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]
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):
@api.model
def _parse_float_from_data(self, data, index, name, options):
thousand_separator = options.get('float_thousand_separator', ' ')
decimal_separator = options.get('float_decimal_separator', '.')
for line in data:
@@ -570,13 +585,14 @@ class ir_import(orm.TransientModel):
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:
line[index] = self._remove_currency_symbol(line[index])
if line[index] is 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):
@api.multi
def _parse_import_data(self, data, import_fields, options):
# Get fields of type date/datetime
all_fields = self.pool[record.res_model].fields_get(cr, uid, context=context)
all_fields = self.env[self.res_model].fields_get()
for name, field in all_fields.iteritems():
if field['type'] in ('date', 'datetime') and name in import_fields:
# Parse date
@@ -592,12 +608,13 @@ class ir_import(orm.TransientModel):
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
# 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)
self._parse_float_from_data(data, index, name, options)
return data
def do(self, cr, uid, id, fields, options, dryrun=False, context=None):
@api.multi
def do(self, fields, options, dryrun=False):
""" Actual execution of the import
:param fields: import mapping: maps each column to a field,
@@ -617,24 +634,22 @@ class ir_import(orm.TransientModel):
``false`` if that data isn't available or provided)
:rtype: list({type, message, record})
"""
cr.execute('SAVEPOINT import')
self.ensure_one()
self._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)
data, import_fields = self._convert_import_data(fields, options)
# Parse date and float field
data = self._parse_import_data(cr, uid, data, import_fields, record, options, context=context)
except ValueError, e:
data = self._parse_import_data(data, import_fields, options)
except ValueError, error:
return [{
'type': 'error',
'message': unicode(e),
'message': unicode(error),
'record': False,
}]
_logger.info('importing %d rows...', len(data))
import_result = self.pool[record.res_model].load(
cr, uid, import_fields, data, context=context)
import_result = self.env[self.res_model].load(import_fields, data)
_logger.info('done')
# If transaction aborted, RELEASE SAVEPOINT is going to raise
@@ -646,9 +661,9 @@ class ir_import(orm.TransientModel):
# error in the results.
try:
if dryrun:
cr.execute('ROLLBACK TO SAVEPOINT import')
self._cr.execute('ROLLBACK TO SAVEPOINT import')
else:
cr.execute('RELEASE SAVEPOINT import')
self._cr.execute('RELEASE SAVEPOINT import')
except psycopg2.InternalError:
pass
+1 -1
View File
@@ -72,7 +72,7 @@ class test_basic_fields(BaseImportCase):
{'id': 'value', 'name': 'id', 'string': 'External ID', 'required': False, 'fields': [], 'type': 'id'},
{'id': 'value', 'name': '.id', 'string': 'Database ID', 'required': False, 'fields': [], 'type': 'id'},
]))
def test_m2o_required(self):
""" If an m2o field is required, its three sub-fields are
required as well (the client has to handle that: requiredness