From 9805c665c89784bdaa5234668ed79ca2280cb124 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Wed, 19 Sep 2012 13:40:47 +0200 Subject: [PATCH 01/43] [ADD] big bit on new import: pretty much everything but o2m bzr revid: xmo@openerp.com-20120919114047-w4paoim95oxr91zb --- openerp/addons/base/ir/__init__.py | 1 + openerp/addons/base/ir/ir_fields.py | 193 ++++ openerp/osv/fields.py | 42 +- openerp/osv/orm.py | 226 +++- .../tests/addons/test_impex/tests/__init__.py | 3 +- .../addons/test_impex/tests/test_load.py | 987 ++++++++++++++++++ openerp/tests/test_misc.py | 38 +- openerp/tools/misc.py | 34 + setup.py | 1 + 9 files changed, 1507 insertions(+), 18 deletions(-) create mode 100644 openerp/addons/base/ir/ir_fields.py create mode 100644 openerp/tests/addons/test_impex/tests/test_load.py diff --git a/openerp/addons/base/ir/__init__.py b/openerp/addons/base/ir/__init__.py index bc4f3eecfbf..ba8b785f39d 100644 --- a/openerp/addons/base/ir/__init__.py +++ b/openerp/addons/base/ir/__init__.py @@ -40,6 +40,7 @@ import wizard import ir_config_parameter import osv_memory_autovacuum import ir_mail_server +import ir_fields # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py new file mode 100644 index 00000000000..f73067c9f6c --- /dev/null +++ b/openerp/addons/base/ir/ir_fields.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +import functools +import operator +import warnings +from openerp.osv import orm, fields +from openerp.tools.translate import _ + +class ConversionNotFound(ValueError): pass + +class ir_fields_converter(orm.Model): + _name = 'ir.fields.converter' + + def to_field(self, cr, uid, model, column, fromtype=str, context=None): + """ Fetches a converter for the provided column object, from the + specified type. + + A converter is simply a callable taking a value of type ``fromtype`` + (or a composite of ``fromtype``, e.g. list or dict) and returning a + value acceptable for a write() on the column ``column``. + + By default, tries to get a method on itself with a name matching the + pattern ``_$fromtype_$column._type`` and returns it. + + :param cr: openerp cursor + :param uid: ID of user calling the converter + :param column: column object to generate a value for + :type column: :class:`fields._column` + :param type fromtype: type to convert to something fitting for ``column`` + :param context: openerp request context + :return: a function (fromtype -> column.write_type), if a converter is found + :rtype: Callable | None + """ + # FIXME: return None + converter = getattr( + self, '_%s_to_%s' % (fromtype.__name__, column._type)) + if not converter: return None + + return functools.partial( + converter, cr, uid, model, column, context=context) + + def _str_to_boolean(self, cr, uid, model, column, value, context=None): + return value.lower() not in ('', '0', 'false', 'off') + + def _str_to_integer(self, cr, uid, model, column, value, context=None): + if not value: return False + return int(value) + + def _str_to_float(self, cr, uid, model, column, value, context=None): + if not value: return False + return float(value) + + def _str_to_char(self, cr, uid, model, column, value, context=None): + return value or False + + def _str_to_text(self, cr, uid, model, column, value, context=None): + return value or False + + def _get_translations(self, cr, uid, types, src, context): + Translations = self.pool['ir.translation'] + tnx_ids = Translations.search( + cr, uid, [('type', 'in', types), ('src', '=', src)], context=context) + tnx = Translations.read(cr, uid, tnx_ids, ['value'], context=context) + return map(operator.itemgetter('value'), tnx) + def _str_to_selection(self, cr, uid, model, column, value, context=None): + + selection = column.selection + if not isinstance(selection, (tuple, list)): + # FIXME: Don't pass context to avoid translations? + # Or just copy context & remove lang? + selection = selection(model, cr, uid) + for item, label in selection: + labels = self._get_translations( + cr, uid, ('selection', 'model'), label, context=context) + labels.append(label) + if value == unicode(item) or value in labels: + return item + raise ValueError( + _(u"Value '%s' not found in selection field '%%(field)s'") % ( + value)) + + + def db_id_for(self, cr, uid, model, column, subfield, value, context=None): + """ Finds a database id for the reference ``value`` in the referencing + subfield ``subfield`` of the provided column of the provided model. + + :param cr: OpenERP cursor + :param uid: OpenERP user id + :param model: model to which the column belongs + :param column: relational column for which references are provided + :param subfield: a relational subfield allowing building of refs to + existing records: ``None`` for a name_get/name_search, + ``id`` for an external id and ``.id`` for a database + id + :param value: value of the reference to match to an actual record + :param context: OpenERP request context + :return: a pair of the matched database identifier (if any) and the + translated user-readable name for the field + :rtype: (ID|None, unicode) + """ + id = None + RelatedModel = self.pool[column._obj] + if subfield == '.id': + field_type = _(u"database id") + try: tentative_id = int(value) + except ValueError: tentative_id = value + if RelatedModel.search(cr, uid, [('id', '=', tentative_id)], + context=context): + id = tentative_id + elif subfield == 'id': + field_type = _(u"external id") + if '.' in value: + module, xid = value.split('.', 1) + else: + module, xid = '', value + ModelData = self.pool['ir.model.data'] + try: + md_id = ModelData._get_id(cr, uid, module, xid) + model_data = ModelData.read(cr, uid, [md_id], ['res_id'], + context=context) + if model_data: + id = model_data[0]['res_id'] + except ValueError: pass # leave id is None + elif subfield is None: + field_type = _(u"name") + ids = RelatedModel.name_search( + cr, uid, name=value, operator='=', context=context) + if ids: + if len(ids) > 1: + warnings.warn( + _(u"Found multiple matches for field '%%(field)s' (%d matches)") + % (len(ids)), orm.ImportWarning) + id, _name = ids[0] + else: + raise Exception(u"Unknown sub-field '%s'" % subfield) + return id, field_type + + def _referencing_subfield(self, record): + """ Checks the record for the subfields allowing referencing (an + existing record in an other table), errors out if it finds potential + conflicts (multiple referencing subfields) or non-referencing subfields + returns the name of the correct subfield. + + :param record: + :return: the record subfield to use for referencing + :rtype: str + """ + # Can import by name_get, external id or database id + allowed_fields = set([None, 'id', '.id']) + fieldset = set(record.iterkeys()) + if fieldset - allowed_fields: + raise ValueError( + _(u"Can not create Many-To-One records indirectly, import the field separately")) + if len(fieldset) > 1: + raise ValueError( + _(u"Ambiguous specification for field '%(field)s', only provide one of name, external id or database id")) + + # only one field left possible, unpack + [subfield] = fieldset + return subfield + + def _str_to_many2one(self, cr, uid, model, column, values, context=None): + # Should only be one record, unpack + [record] = values + + subfield = self._referencing_subfield(record) + + reference = record[subfield] + id, subfield_type = self.db_id_for( + cr, uid, model, column, subfield, reference, context=context) + + if id is None: + raise ValueError( + _(u"No matching record found for %(field_type)s '%(value)s' in field '%%(field)s'") + % {'field_type': subfield_type, 'value': reference}) + return id + def _str_to_many2many(self, cr, uid, model, column, value, context=None): + [record] = value + + subfield = self._referencing_subfield(record) + + ids = [] + for reference in record[subfield].split(','): + id, subfield_type = self.db_id_for( + cr, uid, model, column, subfield, reference, context=context) + if id is None: + raise ValueError( + _(u"No matching record found for %(field_type)s '%(value)s' in field '%%(field)s'") + % {'field_type': subfield_type, 'value': reference}) + ids.append(id) + + return [(6, 0, ids)] + def _str_to_one2many(self, cr, uid, model, column, value, context=None): + return value diff --git a/openerp/osv/fields.py b/openerp/osv/fields.py index 037d2a90986..36c6600a7e5 100644 --- a/openerp/osv/fields.py +++ b/openerp/osv/fields.py @@ -1588,19 +1588,32 @@ def field_to_dict(model, cr, user, field, context=None): class column_info(object): - """Struct containing details about an osv column, either one local to - its model, or one inherited via _inherits. + """ Struct containing details about an osv column, either one local to + its model, or one inherited via _inherits. - :attr name: name of the column - :attr column: column instance, subclass of osv.fields._column - :attr parent_model: if the column is inherited, name of the model - that contains it, None for local columns. - :attr parent_column: the name of the column containing the m2o - relationship to the parent model that contains - this column, None for local columns. - :attr original_parent: if the column is inherited, name of the original - parent model that contains it i.e in case of multilevel - inheritence, None for local columns. + .. attribute:: name + + name of the column + + .. attribute:: column + + column instance, subclass of :class:`_column` + + .. attribute:: parent_model + + if the column is inherited, name of the model that contains it, + ``None`` for local columns. + + .. attribute:: parent_column + + the name of the column containing the m2o relationship to the + parent model that contains this column, ``None`` for local columns. + + .. attribute:: original_parent + + if the column is inherited, name of the original parent model that + contains it i.e in case of multilevel inheritance, ``None`` for + local columns. """ def __init__(self, name, column, parent_model=None, parent_column=None, original_parent=None): self.name = name @@ -1609,5 +1622,10 @@ class column_info(object): self.parent_column = parent_column self.original_parent = original_parent + def __str__(self): + return '%s(%s, %s, %s, %s, %s)' % ( + self.__name__, self.name, self.column, + self.parent_model, self.parent_column, self.original_parent) + # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index 9d3b92a3d31..57cc5823dd9 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -52,13 +52,17 @@ import re import simplejson import time import types + +import psycopg2 from lxml import etree +import warnings import fields import openerp import openerp.netsvc as netsvc import openerp.tools as tools from openerp.tools.config import config +from openerp.tools.misc import CountingStream from openerp.tools.safe_eval import safe_eval as eval from openerp.tools.translate import _ from openerp import SUPERUSER_ID @@ -1242,7 +1246,7 @@ class BaseModel(object): * The last item is currently unused, with no specific semantics :param fields: list of fields to import - :param data: data to import + :param datas: data to import :param mode: 'init' or 'update' for record creation :param current_module: module name :param noupdate: flag for record creation @@ -1438,6 +1442,199 @@ class BaseModel(object): self._parent_store_compute(cr) return position, 0, 0, 0 + def load(self, cr, uid, fields, data, context=None): + """ + + :param cr: cursor for the request + :param int uid: ID of the user attempting the data import + :param fields: list of fields to import, at the same index as the corresponding data + :type fields: list(str) + :param data: row-major matrix of data to import + :type data: list(list(str)) + :param dict context: + :returns: + """ + cr.execute('SAVEPOINT model_load') + messages = [] + + fields = map(fix_import_export_id_paths, fields) + ModelData = self.pool['ir.model.data'] + + mode = 'init' + current_module = '' + noupdate = False + + ids = [] + for id, xid, record, info in self._convert_records(cr, uid, + self._extract_records(cr, uid, fields, data, + context=context, log=messages.append), + context=context, log=messages.append): + cr.execute('SAVEPOINT model_load_save') + try: + ids.append(ModelData._update(cr, uid, self._name, + current_module, record, mode=mode, xml_id=xid, + noupdate=noupdate, res_id=id, context=context)) + cr.execute('RELEASE SAVEPOINT model_load_save') + except psycopg2.Error, e: + # Failed to write, log to messages, rollback savepoint (to + # avoid broken transaction) and keep going + cr.execute('ROLLBACK TO SAVEPOINT model_load_save') + messages.append(dict(info, type="error", message=str(e))) + if any(message['type'] == 'error' for message in messages): + cr.execute('ROLLBACK TO SAVEPOINT model_load') + return False, messages + return ids, messages + def _extract_records(self, cr, uid, fields_, data, + context=None, log=lambda a: None): + """ Generates record dicts from the data iterable. + + The result is a generator of dicts mapping field names to raw + (unconverted, unvalidated) values. + + For relational fields, if sub-fields were provided the value will be + a list of sub-records + + The following sub-fields may be set on the record (by key): + * None is the name_get for the record (to use with name_create/name_search) + * "id" is the External ID for the record + * ".id" is the Database ID for the record + + :param ImportLogger logger: + """ + columns = dict((k, v.column) for k, v in self._all_columns.iteritems()) + # Fake columns to avoid special cases in extractor + columns[None] = fields.char('rec_name') + columns['id'] = fields.char('External ID') + columns['.id'] = fields.integer('Database ID') + + # m2o fields can't be on multiple lines so exclude them from the + # is_relational field rows filter, but special-case it later on to + # be handled with relational fields (as it can have subfields) + is_relational = lambda field: columns[field]._type in ('one2many', 'many2many', 'many2one') + get_o2m_values = itemgetter_tuple( + [index for index, field in enumerate(fields_) + if columns[field[0]]._type == 'one2many']) + get_nono2m_values = itemgetter_tuple( + [index for index, field in enumerate(fields_) + if columns[field[0]]._type != 'one2many']) + # Checks if the provided row has any non-empty non-relational field + def only_o2m_values(row, f=get_nono2m_values, g=get_o2m_values): + return any(g(row)) and not any(f(row)) + + rows = CountingStream(data) + while True: + row = next(rows, None) + if row is None: return + record_row_index = rows.index + + # copy non-relational fields to record dict + record = dict((field[0], value) + for field, value in itertools.izip(fields_, row) + if not is_relational(field[0])) + + # Get all following rows which have relational values attached to + # the current record (no non-relational values) + # WARNING: replaces existing ``rows`` + record_span, _rows = span(only_o2m_values, rows) + # stitch record row back on for relational fields + record_span = itertools.chain([row], record_span) + for relfield in set( + field[0] for field in fields_ + if is_relational(field[0])): + column = columns[relfield] + # FIXME: how to not use _obj without relying on fields_get? + Model = self.pool[column._obj] + + # copy stream to reuse for next relational field + fieldrows, record_span = itertools.tee(record_span) + # get only cells for this sub-field, should be strictly + # non-empty, field path [None] is for name_get column + indices, subfields = zip(*((index, field[1:] or [None]) + for index, field in enumerate(fields_) + if field[0] == relfield)) + + # return all rows which have at least one value for the + # subfields of relfield + relfield_data = filter(any, map(itemgetter_tuple(indices), fieldrows)) + record[relfield] = [subrecord + for subrecord, _subinfo in Model._extract_records( + cr, uid, subfields, relfield_data, + context=context, log=log)] + # Ensure full consumption of the span (and therefore advancement of + # ``rows``) even if there are no relational fields. Needs two as + # the code above stiched the row back on (so first call may only + # get the stiched row without advancing the underlying operator row + # itself) + next(record_span, None) + next(record_span, None) + + # old rows consumption (by iterating the span) should be done here, + # at this point the old ``rows`` is 1 past `span` (either on the + # next record row or past ``StopIteration``, so wrap new ``rows`` + # (``_rows``) in a counting stream indexed 1-before the old + # ``rows`` + rows = CountingStream(_rows, rows.index - 1) + yield record, {'rows': {'from': record_row_index,'to': rows.index}} + def _convert_records(self, cr, uid, records, + context=None, log=lambda a: None): + """ Converts records from the source iterable (recursive dicts of + strings) into forms which can be written to the database (via + self.create or (ir.model.data)._update) + + :param ImportLogger parent_logger: + :returns: a list of triplets of (id, xid, record) + :rtype: list((int|None, str|None, dict)) + """ + Converter = self.pool['ir.fields.converter'] + columns = dict((k, v.column) for k, v in self._all_columns.iteritems()) + converters = dict( + (k, Converter.to_field(cr, uid, self, column, context=context)) + for k, column in columns.iteritems()) + + stream = CountingStream(records) + for record, extras in stream: + dbid = False + xid = False + converted = {} + # name_get/name_create + if None in record: pass + # xid + if 'id' in record: + xid = record['id'] + # dbid + if '.id' in record: + try: + dbid = int(record['.id']) + except ValueError: + # in case of overridden id column + dbid = record['.id'] + if not self.search(cr, uid, [('id', '=', dbid)], context=context): + log(dict(extras, + type='error', + record=stream.index, + field='.id', + message=_(u"Unknown database identifier '%s'") % dbid)) + dbid = False + + for field, strvalue in record.iteritems(): + if field in (None, 'id', '.id'): continue + + message_base = dict(extras, record=stream.index, field=field) + with warnings.catch_warnings(record=True) as w: + try: + converted[field] = converters[field](strvalue) + + for warning in w: + log(dict(message_base, type='warning', + message=unicode(warning.message) % message_base)) + except ValueError, e: + log(dict(message_base, + type='error', + message=unicode(e) % message_base + )) + + yield dbid, xid, converted, dict(extras, record=stream.index) + def get_invalid_fields(self, cr, uid): return list(self._invalids) @@ -5108,5 +5305,32 @@ class AbstractModel(BaseModel): _auto = False # don't create any database backend for AbstractModels _register = False # not visible in ORM registry, meant to be python-inherited only +def span(predicate, iterable): + """ Splits the iterable between the longest prefix of ``iterable`` whose + elements satisfy ``predicate`` and the rest. + If called with a list, equivalent to:: + + takewhile(predicate, lst), dropwhile(predicate, lst) + + :param callable predicate: + :param iterable: + :rtype: (iterable, iterable) + """ + it1, it2 = itertools.tee(iterable) + return (itertools.takewhile(predicate, it1), + itertools.dropwhile(predicate, it2)) +def itemgetter_tuple(items): + """ Fixes itemgetter inconsistency (useful in some cases) of not returning + a tuple if len(items) == 1: always returns an n-tuple where n = len(items) + """ + if len(items) == 0: + return lambda a: () + if len(items) == 1: + return lambda gettable: (gettable[items[0]],) + return operator.itemgetter(*items) +class ImportWarning(Warning): + """ Used to send warnings upwards the stack during the import process + """ + pass # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/openerp/tests/addons/test_impex/tests/__init__.py b/openerp/tests/addons/test_impex/tests/__init__.py index d6af53cba1b..8cab56fcc9a 100644 --- a/openerp/tests/addons/test_impex/tests/__init__.py +++ b/openerp/tests/addons/test_impex/tests/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from . import test_export, test_import +from . import test_export, test_import, test_load fast_suite = [ ] @@ -8,6 +8,7 @@ fast_suite = [ checks = [ test_export, test_import, + test_load, ] # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py new file mode 100644 index 00000000000..80e5cfe9c8f --- /dev/null +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -0,0 +1,987 @@ +# -*- coding: utf-8 -*- +import openerp.modules.registry +import openerp + +from openerp.tests import common +from openerp.tools.misc import mute_logger + +def message(msg, type='error', from_=0, to_=0, record=0, field='value'): + return { + 'type': type, + 'rows': {'from': from_, 'to': to_}, + 'record': record, + 'field': field, + 'message': msg + } + +def error(row, message, record=None, **kwargs): + """ Failed import of the record ``record`` at line ``row``, with the error + message ``message`` + + :param str message: + :param dict record: + """ + return ( + -1, dict(record or {}, **kwargs), + "Line %d : %s" % (row, message), + '') + +def values(seq, field='value'): + return [item[field] for item in seq] + +class ImporterCase(common.TransactionCase): + model_name = False + + def __init__(self, *args, **kwargs): + super(ImporterCase, self).__init__(*args, **kwargs) + self.model = None + + def setUp(self): + super(ImporterCase, self).setUp() + self.model = self.registry(self.model_name) + self.registry('ir.model.data').clear_caches() + + def import_(self, fields, rows, context=None): + return self.model.load( + self.cr, openerp.SUPERUSER_ID, fields, rows, context=context) + def read(self, fields=('value',), domain=(), context=None): + return self.model.read( + self.cr, openerp.SUPERUSER_ID, + self.model.search(self.cr, openerp.SUPERUSER_ID, domain, context=context), + fields=fields, context=context) + def browse(self, domain=(), context=None): + return self.model.browse( + self.cr, openerp.SUPERUSER_ID, + self.model.search(self.cr, openerp.SUPERUSER_ID, domain, context=context), + context=context) + + def xid(self, record): + ModelData = self.registry('ir.model.data') + + ids = ModelData.search( + self.cr, openerp.SUPERUSER_ID, + [('model', '=', record._table_name), ('res_id', '=', record.id)]) + if ids: + d = ModelData.read( + self.cr, openerp.SUPERUSER_ID, ids, ['name', 'module'])[0] + if d['module']: + return '%s.%s' % (d['module'], d['name']) + return d['name'] + + name = dict(record.name_get())[record.id] + # fix dotted name_get results, otherwise xid lookups blow up + name = name.replace('.', '-') + ModelData.create(self.cr, openerp.SUPERUSER_ID, { + 'name': name, + 'model': record._table_name, + 'res_id': record.id, + 'module': '__test__' + }) + return '__test__.' + name + +class test_ids_stuff(ImporterCase): + model_name = 'export.integer' + + def test_create_with_id(self): + ids, messages = self.import_(['.id', 'value'], [['42', '36']]) + self.assertIs(ids, False) + self.assertEqual(messages, [{ + 'type': 'error', + 'rows': {'from': 0, 'to': 0}, + 'record': 0, + 'field': '.id', + 'message': u"Unknown database identifier '42'", + }]) + def test_create_with_xid(self): + ids, messages = self.import_(['id', 'value'], [['somexmlid', '42']]) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + self.assertEqual( + 'somexmlid', + self.xid(self.browse()[0])) + + def test_update_with_id(self): + id = self.model.create(self.cr, openerp.SUPERUSER_ID, {'value': 36}) + self.assertEqual( + 36, + self.model.browse(self.cr, openerp.SUPERUSER_ID, id).value) + + ids, messages = self.import_(['.id', 'value'], [[str(id), '42']]) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + self.assertEqual( + [42], # updated value to imported + values(self.read())) + + def test_update_with_xid(self): + self.import_(['id', 'value'], [['somexmlid', '36']]) + self.assertEqual([36], values(self.read())) + + self.import_(['id', 'value'], [['somexmlid', '1234567']]) + self.assertEqual([1234567], values(self.read())) + +class test_boolean_field(ImporterCase): + model_name = 'export.boolean' + + def test_empty(self): + self.assertEqual( + self.import_(['value'], []), + ([], [])) + + def test_exported(self): + ids, messages = self.import_(['value'], [['False'], ['True'], ]) + self.assertEqual(len(ids), 2) + self.assertFalse(messages) + records = self.read() + self.assertEqual([ + False, + True, + ], values(records)) + + def test_falses(self): + ids, messages = self.import_( + ['value'], + [[u'0'], [u'off'], + [u'false'], [u'FALSE'], + [u'OFF'], [u''], + ]) + self.assertEqual(len(ids), 6) + self.assertFalse(messages) + self.assertEqual([ + False, + False, + False, + False, + False, + False, + ], + values(self.read())) + + def test_trues(self): + ids, messages = self.import_( + ['value'], + [['no'], + ['None'], + ['nil'], + ['()'], + ['f'], + ['#f'], + # Problem: OpenOffice (and probably excel) output localized booleans + ['VRAI'], + ]) + self.assertEqual(len(ids), 7) + # FIXME: should warn for values which are not "true", "yes" or "1" + self.assertFalse(messages) + self.assertEqual( + [True] * 7, + values(self.read())) + +class test_integer_field(ImporterCase): + model_name = 'export.integer' + + def test_none(self): + self.assertEqual( + self.import_(['value'], []), + ([], [])) + + def test_empty(self): + ids, messages = self.import_(['value'], [['']]) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + self.assertEqual( + [False], + values(self.read())) + + def test_zero(self): + ids, messages = self.import_(['value'], [['0']]) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + + ids, messages = self.import_(['value'], [['-0']]) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + + self.assertEqual([False, False], values(self.read())) + + def test_positives(self): + ids, messages = self.import_(['value'], [ + ['1'], + ['42'], + [str(2**31-1)], + ['12345678'] + ]) + self.assertEqual(len(ids), 4) + self.assertFalse(messages) + + self.assertEqual([ + 1, 42, 2**31-1, 12345678 + ], values(self.read())) + + def test_negatives(self): + ids, messages = self.import_(['value'], [ + ['-1'], + ['-42'], + [str(-(2**31 - 1))], + [str(-(2**31))], + ['-12345678'] + ]) + self.assertEqual(len(ids), 5) + self.assertFalse(messages) + self.assertEqual([ + -1, -42, -(2**31 - 1), -(2**31), -12345678 + ], values(self.read())) + + @mute_logger('openerp.sql_db') + def test_out_of_range(self): + ids, messages = self.import_(['value'], [[str(2**31)]]) + self.assertIs(ids, False) + self.assertEqual(messages, [{ + 'type': 'error', + 'rows': {'from': 0, 'to': 0}, + 'record': 0, + 'message': "integer out of range\n" + }]) + + ids, messages = self.import_(['value'], [[str(-2**32)]]) + self.assertIs(ids, False) + self.assertEqual(messages, [{ + 'type': 'error', + 'rows': {'from': 0, 'to': 0}, + 'record': 0, + 'message': "integer out of range\n" + }]) + + def test_nonsense(self): + ids, messages = self.import_(['value'], [['zorglub']]) + self.assertIs(ids, False) + self.assertEqual(messages, [{ + 'type': 'error', + 'rows': {'from': 0, 'to': 0}, + 'record': 0, + 'field': 'value', + 'message': u"invalid literal for int() with base 10: 'zorglub'", + }]) + +class test_float_field(ImporterCase): + model_name = 'export.float' + def test_none(self): + self.assertEqual( + self.import_(['value'], []), + ([], [])) + + def test_empty(self): + ids, messages = self.import_(['value'], [['']]) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + self.assertEqual( + [False], + values(self.read())) + + def test_zero(self): + ids, messages = self.import_(['value'], [['0']]) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + + ids, messages = self.import_(['value'], [['-0']]) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + + self.assertEqual([False, False], values(self.read())) + + def test_positives(self): + ids, messages = self.import_(['value'], [ + ['1'], + ['42'], + [str(2**31-1)], + ['12345678'], + [str(2**33)], + ['0.000001'], + ]) + self.assertEqual(len(ids), 6) + self.assertFalse(messages) + + self.assertEqual([ + 1, 42, 2**31-1, 12345678, 2.0**33, .000001 + ], values(self.read())) + + def test_negatives(self): + ids, messages = self.import_(['value'], [ + ['-1'], + ['-42'], + [str(-2**31 + 1)], + [str(-2**31)], + ['-12345678'], + [str(-2**33)], + ['-0.000001'], + ]) + self.assertEqual(len(ids), 7) + self.assertFalse(messages) + self.assertEqual([ + -1, -42, -(2**31 - 1), -(2**31), -12345678, -2.0**33, -.000001 + ], values(self.read())) + + def test_nonsense(self): + ids, messages = self.import_(['value'], [['foobar']]) + self.assertIs(ids, False) + self.assertEqual(messages, [{ + 'type': 'error', + 'rows': {'from': 0, 'to': 0}, + 'record': 0, + 'field': 'value', + 'message': u"invalid literal for float(): foobar", + }]) + +class test_string_field(ImporterCase): + model_name = 'export.string.bounded' + + def test_empty(self): + ids, messages = self.import_(['value'], [['']]) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + self.assertEqual([False], values(self.read())) + + def test_imported(self): + ids, messages = self.import_(['value'], [ + [u'foobar'], + [u'foobarbaz'], + [u'Með suð í eyrum við spilum endalaust'], + [u"People 'get' types. They use them all the time. Telling " + u"someone he can't pound a nail with a banana doesn't much " + u"surprise him."] + ]) + self.assertEqual(len(ids), 4) + self.assertFalse(messages) + self.assertEqual([ + u"foobar", + u"foobarbaz", + u"Með suð í eyrum ", + u"People 'get' typ", + ], values(self.read())) + +class test_unbound_string_field(ImporterCase): + model_name = 'export.string' + + def test_imported(self): + ids, messages = self.import_(['value'], [ + [u'í dag viðrar vel til loftárása'], + # ackbar.jpg + [u"If they ask you about fun, you tell them – fun is a filthy" + u" parasite"] + ]) + self.assertEqual(len(ids), 2) + self.assertFalse(messages) + self.assertEqual([ + u"í dag viðrar vel til loftárása", + u"If they ask you about fun, you tell them – fun is a filthy parasite" + ], values(self.read())) + +class test_text(ImporterCase): + model_name = 'export.text' + + def test_empty(self): + ids, messages = self.import_(['value'], [['']]) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + self.assertEqual([False], values(self.read())) + + def test_imported(self): + s = (u"Breiðskífa er notað um útgefna hljómplötu sem inniheldur " + u"stúdíóupptökur frá einum flytjanda. Breiðskífur eru oftast " + u"milli 25-80 mínútur og er lengd þeirra oft miðuð við 33⅓ " + u"snúninga 12 tommu vínylplötur (sem geta verið allt að 30 mín " + u"hvor hlið).\n\nBreiðskífur eru stundum tvöfaldar og eru þær þá" + u" gefnar út á tveimur geisladiskum eða tveimur vínylplötum.") + ids, messages = self.import_(['value'], [[s]]) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + self.assertEqual([s], values(self.read())) + +class test_selection(ImporterCase): + model_name = 'export.selection' + translations_fr = [ + ("Qux", "toto"), + ("Bar", "titi"), + ("Foo", "tete"), + ] + + def test_imported(self): + ids, messages = self.import_(['value'], [ + ['Qux'], + ['Bar'], + ['Foo'], + ['2'], + ]) + self.assertEqual(len(ids), 4) + self.assertFalse(messages) + self.assertEqual([3, 2, 1, 2], values(self.read())) + + def test_imported_translated(self): + self.registry('res.lang').create(self.cr, openerp.SUPERUSER_ID, { + 'name': u'Français', + 'code': 'fr_FR', + 'translatable': True, + 'date_format': '%d.%m.%Y', + 'decimal_point': ',', + 'thousand_sep': ' ', + }) + Translations = self.registry('ir.translation') + for source, value in self.translations_fr: + Translations.create(self.cr, openerp.SUPERUSER_ID, { + 'name': 'export.selection,value', + 'lang': 'fr_FR', + 'type': 'selection', + 'src': source, + 'value': value + }) + + ids, messages = self.import_(['value'], [ + ['toto'], + ['tete'], + ['titi'], + ], context={'lang': 'fr_FR'}) + self.assertEqual(len(ids), 3) + self.assertFalse(messages) + + self.assertEqual([3, 1, 2], values(self.read())) + + ids, messages = self.import_(['value'], [['Foo']], context={'lang': 'fr_FR'}) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + + def test_invalid(self): + ids, messages = self.import_(['value'], [['Baz']]) + self.assertIs(ids, False) + self.assertEqual(messages, [{ + 'type': 'error', + 'rows': {'from': 0, 'to': 0}, + 'record': 0, + 'field': 'value', + 'message': "Value 'Baz' not found in selection field 'value'", + }]) + + ids, messages = self.import_(['value'], [[42]]) + self.assertIs(ids, False) + self.assertEqual(messages, [{ + 'type': 'error', + 'rows': {'from': 0, 'to': 0}, + 'record': 0, + 'field': 'value', + 'message': "Value '42' not found in selection field 'value'", + }]) + +class test_selection_function(ImporterCase): + model_name = 'export.selection.function' + translations_fr = [ + ("Corge", "toto"), + ("Grault", "titi"), + ("Whee", "tete"), + ("Moog", "tutu"), + ] + + def test_imported(self): + """ import uses fields_get, so translates import label (may or may not + be good news) *and* serializes the selection function to reverse it: + import does not actually know that the selection field uses a function + """ + # NOTE: conflict between a value and a label => ? + ids, messages = self.import_(['value'], [ + ['3'], + ["Grault"], + ]) + self.assertEqual(len(ids), 2) + self.assertFalse(messages) + self.assertEqual( + ['3', '1'], + values(self.read())) + + def test_translated(self): + """ Expects output of selection function returns translated labels + """ + self.registry('res.lang').create(self.cr, openerp.SUPERUSER_ID, { + 'name': u'Français', + 'code': 'fr_FR', + 'translatable': True, + 'date_format': '%d.%m.%Y', + 'decimal_point': ',', + 'thousand_sep': ' ', + }) + Translations = self.registry('ir.translation') + for source, value in self.translations_fr: + Translations.create(self.cr, openerp.SUPERUSER_ID, { + 'name': 'export.selection,value', + 'lang': 'fr_FR', + 'type': 'selection', + 'src': source, + 'value': value + }) + ids, messages = self.import_(['value'], [ + ['toto'], + ['tete'], + ], context={'lang': 'fr_FR'}) + self.assertIs(ids, False) + self.assertEqual(messages, [{ + 'type': 'error', + 'rows': {'from': 1, 'to': 1}, + 'record': 1, + 'field': 'value', + 'message': "Value 'tete' not found in selection field 'value'", + }]) + ids, messages = self.import_(['value'], [['Wheee']], context={'lang': 'fr_FR'}) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + +class test_m2o(ImporterCase): + model_name = 'export.many2one' + + def test_by_name(self): + # create integer objects + integer_id1 = self.registry('export.integer').create( + self.cr, openerp.SUPERUSER_ID, {'value': 42}) + integer_id2 = self.registry('export.integer').create( + self.cr, openerp.SUPERUSER_ID, {'value': 36}) + # get its name + name1 = dict(self.registry('export.integer').name_get( + self.cr, openerp.SUPERUSER_ID,[integer_id1]))[integer_id1] + name2 = dict(self.registry('export.integer').name_get( + self.cr, openerp.SUPERUSER_ID,[integer_id2]))[integer_id2] + + ids , messages = self.import_(['value'], [ + # import by name_get + [name1], + [name1], + [name2], + ]) + self.assertFalse(messages) + self.assertEqual(len(ids), 3) + # correct ids assigned to corresponding records + self.assertEqual([ + (integer_id1, name1), + (integer_id1, name1), + (integer_id2, name2),], + values(self.read())) + + def test_by_xid(self): + ExportInteger = self.registry('export.integer') + integer_id = ExportInteger.create( + self.cr, openerp.SUPERUSER_ID, {'value': 42}) + xid = self.xid(ExportInteger.browse( + self.cr, openerp.SUPERUSER_ID, [integer_id])[0]) + + ids, messages = self.import_(['value/id'], [[xid]]) + self.assertFalse(messages) + self.assertEqual(len(ids), 1) + b = self.browse() + self.assertEqual(42, b[0].value.value) + + def test_by_id(self): + integer_id = self.registry('export.integer').create( + self.cr, openerp.SUPERUSER_ID, {'value': 42}) + ids, messages = self.import_(['value/.id'], [[integer_id]]) + self.assertFalse(messages) + self.assertEqual(len(ids), 1) + b = self.browse() + self.assertEqual(42, b[0].value.value) + + def test_by_names(self): + integer_id1 = self.registry('export.integer').create( + self.cr, openerp.SUPERUSER_ID, {'value': 42}) + integer_id2 = self.registry('export.integer').create( + self.cr, openerp.SUPERUSER_ID, {'value': 42}) + name1 = dict(self.registry('export.integer').name_get( + self.cr, openerp.SUPERUSER_ID,[integer_id1]))[integer_id1] + name2 = dict(self.registry('export.integer').name_get( + self.cr, openerp.SUPERUSER_ID,[integer_id2]))[integer_id2] + # names should be the same + self.assertEqual(name1, name2) + + ids, messages = self.import_(['value'], [[name2]]) + self.assertEqual( + messages, + [message(u"Found multiple matches for field 'value' (2 matches)", + type='warning')]) + self.assertEqual(len(ids), 1) + self.assertEqual([ + (integer_id1, name1) + ], values(self.read())) + + def test_fail_by_implicit_id(self): + """ Can't implicitly import records by id + """ + # create integer objects + integer_id1 = self.registry('export.integer').create( + self.cr, openerp.SUPERUSER_ID, {'value': 42}) + integer_id2 = self.registry('export.integer').create( + self.cr, openerp.SUPERUSER_ID, {'value': 36}) + + # Because name_search all the things. Fallback schmallback + ids, messages = self.import_(['value'], [ + # import by id, without specifying it + [integer_id1], + [integer_id2], + [integer_id1], + ]) + self.assertEqual(messages, [ + message(u"No matching record found for name '%s' in field 'value'" % id, + from_=index, to_=index, record=index) + for index, id in enumerate([integer_id1, integer_id2, integer_id1])]) + self.assertIs(ids, False) + + def test_sub_field(self): + """ Does not implicitly create the record, does not warn that you can't + import m2o subfields (at all)... + """ + ids, messages = self.import_(['value/value'], [['42']]) + self.assertEqual(messages, [ + message(u"Can not create Many-To-One records indirectly, import " + u"the field separately")]) + self.assertIs(ids, False) + + def test_fail_noids(self): + ids, messages = self.import_(['value'], [['nameisnoexist:3']]) + self.assertEqual(messages, [message( + u"No matching record found for name 'nameisnoexist:3' " + u"in field 'value'")]) + self.assertIs(ids, False) + + ids, messages = self.import_(['value/id'], [['noxidhere']]) + self.assertEqual(messages, [message( + u"No matching record found for external id 'noxidhere' " + u"in field 'value'")]) + self.assertIs(ids, False) + + ids, messages = self.import_(['value/.id'], [['66']]) + self.assertEqual(messages, [message( + u"No matching record found for database id '66' " + u"in field 'value'")]) + self.assertIs(ids, False) + + def test_fail_multiple(self): + ids, messages = self.import_( + ['value', 'value/id'], + [['somename', 'somexid']]) + self.assertEqual(messages, [message( + u"Ambiguous specification for field 'value', only provide one of " + u"name, external id or database id")]) + self.assertIs(ids, False) + +class test_m2m(ImporterCase): + model_name = 'export.many2many' + + # apparently, one and only thing which works is a + # csv_internal_sep-separated list of ids, xids, or names (depending if + # m2m/.id, m2m/id or m2m[/anythingelse] + def test_ids(self): + id1 = self.registry('export.many2many.other').create( + self.cr, openerp.SUPERUSER_ID, {'value': 3, 'str': 'record0'}) + id2 = self.registry('export.many2many.other').create( + self.cr, openerp.SUPERUSER_ID, {'value': 44, 'str': 'record1'}) + id3 = self.registry('export.many2many.other').create( + self.cr, openerp.SUPERUSER_ID, {'value': 84, 'str': 'record2'}) + id4 = self.registry('export.many2many.other').create( + self.cr, openerp.SUPERUSER_ID, {'value': 9, 'str': 'record3'}) + id5 = self.registry('export.many2many.other').create( + self.cr, openerp.SUPERUSER_ID, {'value': 99, 'str': 'record4'}) + + ids, messages = self.import_(['value/.id'], [ + ['%d,%d' % (id1, id2)], + ['%d,%d,%d' % (id1, id3, id4)], + ['%d,%d,%d' % (id1, id2, id3)], + ['%d' % id5] + ]) + self.assertFalse(messages) + self.assertEqual(len(ids), 4) + + ids = lambda records: [record.id for record in records] + + b = self.browse() + self.assertEqual(ids(b[0].value), [id1, id2]) + self.assertEqual(values(b[0].value), [3, 44]) + + self.assertEqual(ids(b[2].value), [id1, id2, id3]) + self.assertEqual(values(b[2].value), [3, 44, 84]) + + def test_noids(self): + ids, messages = self.import_(['value/.id'], [['42']]) + self.assertEqual(messages, [message( + u"No matching record found for database id '42' in field " + u"'value'")]) + self.assertIs(ids, False) + + def test_xids(self): + M2O_o = self.registry('export.many2many.other') + id1 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 3, 'str': 'record0'}) + id2 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 44, 'str': 'record1'}) + id3 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 84, 'str': 'record2'}) + id4 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 9, 'str': 'record3'}) + records = M2O_o.browse(self.cr, openerp.SUPERUSER_ID, [id1, id2, id3, id4]) + + ids, messages = self.import_(['value/id'], [ + ['%s,%s' % (self.xid(records[0]), self.xid(records[1]))], + ['%s' % self.xid(records[3])], + ['%s,%s' % (self.xid(records[2]), self.xid(records[1]))], + ]) + self.assertFalse(messages) + self.assertEqual(len(ids), 3) + + b = self.browse() + self.assertEqual(values(b[0].value), [3, 44]) + self.assertEqual(values(b[2].value), [44, 84]) + def test_noxids(self): + ids, messages = self.import_(['value/id'], [['noxidforthat']]) + self.assertEqual(messages, [message( + u"No matching record found for external id 'noxidforthat' " + u"in field 'value'")]) + self.assertIs(ids, False) + + def test_names(self): + M2O_o = self.registry('export.many2many.other') + id1 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 3, 'str': 'record0'}) + id2 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 44, 'str': 'record1'}) + id3 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 84, 'str': 'record2'}) + id4 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 9, 'str': 'record3'}) + records = M2O_o.browse(self.cr, openerp.SUPERUSER_ID, [id1, id2, id3, id4]) + + name = lambda record: dict(record.name_get())[record.id] + + ids, messages = self.import_(['value'], [ + ['%s,%s' % (name(records[1]), name(records[2]))], + ['%s,%s,%s' % (name(records[0]), name(records[1]), name(records[2]))], + ['%s,%s' % (name(records[0]), name(records[3]))], + ]) + self.assertFalse(messages) + self.assertEqual(len(ids), 3) + + b = self.browse() + self.assertEqual(values(b[1].value), [3, 44, 84]) + self.assertEqual(values(b[2].value), [3, 9]) + + def test_nonames(self): + ids, messages = self.import_(['value'], [['wherethem2mhavenonames']]) + self.assertEqual(messages, [message( + u"No matching record found for name 'wherethem2mhavenonames' in " + u"field 'value'")]) + self.assertIs(ids, False) + + def test_import_to_existing(self): + M2O_o = self.registry('export.many2many.other') + id1 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 3, 'str': 'record0'}) + id2 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 44, 'str': 'record1'}) + id3 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 84, 'str': 'record2'}) + id4 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 9, 'str': 'record3'}) + + xid = 'myxid' + ids, messages = self.import_(['id', 'value/.id'], [[xid, '%d,%d' % (id1, id2)]]) + self.assertFalse(messages) + self.assertEqual(len(ids), 1) + ids, messages = self.import_(['id', 'value/.id'], [[xid, '%d,%d' % (id3, id4)]]) + self.assertFalse(messages) + self.assertEqual(len(ids), 1) + + b = self.browse() + self.assertEqual(len(b), 1) + # TODO: replacement of existing m2m values is correct? + self.assertEqual(values(b[0].value), [84, 9]) + +class test_o2m(ImporterCase): + model_name = 'export.one2many' + + def test_name_get(self): + # FIXME: bloody hell why can't this just name_create the record? + self.assertRaises( + IndexError, + self.import_, + ['const', 'value'], + [['5', u'Java is a DSL for taking large XML files' + u' and converting them to stack traces']]) + + def test_single(self): + ids, messages = self.import_(['const', 'value/value'], [ + ['5', '63'] + ]) + self.assertEqual(len(ids), 1) + self.assertFalse(messages) + + (b,) = self.browse() + self.assertEqual(b.const, 5) + self.assertEqual(values(b.value), [63]) + + def test_multicore(self): + ids, messages = self.import_(['const', 'value/value'], [ + ['5', '63'], + ['6', '64'], + ]) + self.assertEqual(len(ids), 2) + self.assertFalse(messages) + + b1, b2 = self.browse() + self.assertEqual(b1.const, 5) + self.assertEqual(values(b1.value), [63]) + self.assertEqual(b2.const, 6) + self.assertEqual(values(b2.value), [64]) + + def test_multisub(self): + ids, messages = self.import_(['const', 'value/value'], [ + ['5', '63'], + ['', '64'], + ['', '65'], + ['', '66'], + ]) + self.assertEqual(len(ids), 4) + self.assertFalse(messages) + + (b,) = self.browse() + self.assertEqual(values(b.value), [63, 64, 65, 66]) + + def test_multi_subfields(self): + ids, messages = self.import_(['value/str', 'const', 'value/value'], [ + ['this', '5', '63'], + ['is', '', '64'], + ['the', '', '65'], + ['rhythm', '', '66'], + ]) + self.assertEqual(len(ids), 4) + self.assertFalse(messages) + + (b,) = self.browse() + self.assertEqual(values(b.value), [63, 64, 65, 66]) + self.assertEqual( + values(b.value, 'str'), + 'this is the rhythm'.split()) + + def test_link_inline(self): + id1 = self.registry('export.one2many.child').create(self.cr, openerp.SUPERUSER_ID, { + 'str': 'Bf', 'value': 109 + }) + id2 = self.registry('export.one2many.child').create(self.cr, openerp.SUPERUSER_ID, { + 'str': 'Me', 'value': 262 + }) + + try: + self.import_(['const', 'value/.id'], [ + ['42', '%d,%d' % (id1, id2)] + ]) + self.fail("Should have raised a valueerror") + except ValueError, e: + # should be Exception(Database ID doesn't exist: export.one2many.child : $id1,$id2) + self.assertIs(type(e), ValueError) + self.assertEqual( + e.args[0], + "invalid literal for int() with base 10: '%d,%d'" % (id1, id2)) + + def test_link(self): + id1 = self.registry('export.one2many.child').create(self.cr, openerp.SUPERUSER_ID, { + 'str': 'Bf', 'value': 109 + }) + id2 = self.registry('export.one2many.child').create(self.cr, openerp.SUPERUSER_ID, { + 'str': 'Me', 'value': 262 + }) + + ids, messages = self.import_(['const', 'value/.id'], [ + ['42', str(id1)], + ['', str(id2)], + ]) + self.assertEqual(len(ids), 2) + self.assertFalse(messages) + + # No record values alongside id => o2m resolution skipped altogether, + # creates 2 records => remove/don't import columns sideshow columns, + # get completely different semantics + b, b1 = self.browse() + self.assertEqual(b.const, 42) + self.assertEqual(values(b.value), []) + self.assertEqual(b1.const, 4) + self.assertEqual(values(b1.value), []) + + def test_link_2(self): + O2M_c = self.registry('export.one2many.child') + id1 = O2M_c.create(self.cr, openerp.SUPERUSER_ID, { + 'str': 'Bf', 'value': 109 + }) + id2 = O2M_c.create(self.cr, openerp.SUPERUSER_ID, { + 'str': 'Me', 'value': 262 + }) + + ids, messages = self.import_(['const', 'value/.id', 'value/value'], [ + ['42', str(id1), '1'], + ['', str(id2), '2'], + ]) + self.assertEqual(len(ids), 2) + self.assertFalse(messages) + + (b,) = self.browse() + # if an id (db or xid) is provided, expectations that objects are + # *already* linked and emits UPDATE (1, id, {}). + # Noid => CREATE (0, ?, {}) + # TODO: xid ignored aside from getting corresponding db id? + self.assertEqual(b.const, 42) + self.assertEqual(values(b.value), []) + + # FIXME: updates somebody else's records? + self.assertEqual( + O2M_c.read(self.cr, openerp.SUPERUSER_ID, id1), + {'id': id1, 'str': 'Bf', 'value': 1, 'parent_id': False}) + self.assertEqual( + O2M_c.read(self.cr, openerp.SUPERUSER_ID, id2), + {'id': id2, 'str': 'Me', 'value': 2, 'parent_id': False}) + +class test_o2m_multiple(ImporterCase): + model_name = 'export.one2many.multiple' + + def test_multi_mixed(self): + ids, messages = self.import_(['const', 'child1/value', 'child2/value'], [ + ['5', '11', '21'], + ['', '12', '22'], + ['', '13', '23'], + ['', '14', ''], + ]) + self.assertEqual(len(ids), 4) + self.assertFalse(messages) + # Oh yeah, that's the stuff + (b, b1, b2) = self.browse() + self.assertEqual(values(b.child1), [11]) + self.assertEqual(values(b.child2), [21]) + + self.assertEqual(values(b1.child1), [12]) + self.assertEqual(values(b1.child2), [22]) + + self.assertEqual(values(b2.child1), [13, 14]) + self.assertEqual(values(b2.child2), [23]) + + def test_multi(self): + ids, messages = self.import_(['const', 'child1/value', 'child2/value'], [ + ['5', '11', '21'], + ['', '12', ''], + ['', '13', ''], + ['', '14', ''], + ['', '', '22'], + ['', '', '23'], + ]) + self.assertEqual(len(ids), 6) + self.assertFalse(messages) + # What the actual fuck? + (b, b1) = self.browse() + self.assertEqual(values(b.child1), [11, 12, 13, 14]) + self.assertEqual(values(b.child2), [21]) + self.assertEqual(values(b1.child2), [22, 23]) + + def test_multi_fullsplit(self): + ids, messages = self.import_(['const', 'child1/value', 'child2/value'], [ + ['5', '11', ''], + ['', '12', ''], + ['', '13', ''], + ['', '14', ''], + ['', '', '21'], + ['', '', '22'], + ['', '', '23'], + ]) + self.assertEqual(len(ids), 7) + self.assertFalse(messages) + # oh wow + (b, b1) = self.browse() + self.assertEqual(b.const, 5) + self.assertEqual(values(b.child1), [11, 12, 13, 14]) + self.assertEqual(b1.const, 36) + self.assertEqual(values(b1.child2), [21, 22, 23]) + +# function, related, reference: written to db as-is... +# => function uses @type for value coercion/conversion diff --git a/openerp/tests/test_misc.py b/openerp/tests/test_misc.py index 7661f253b17..54ae69899a6 100644 --- a/openerp/tests/test_misc.py +++ b/openerp/tests/test_misc.py @@ -2,12 +2,12 @@ # > PYTHONPATH=. python2 openerp/tests/test_misc.py import unittest2 +from ..tools import misc -class test_misc(unittest2.TestCase): +class append_content_to_html(unittest2.TestCase): """ Test some of our generic utility functions """ def test_append_to_html(self): - from openerp.tools import append_content_to_html test_samples = [ ('some content', '--\nYours truly', True, 'some content\n
--\nYours truly
\n'), @@ -15,7 +15,37 @@ class test_misc(unittest2.TestCase): 'some content\n\n\n

--

\n

Yours truly

\n\n\n'), ] for html, content, flag, expected in test_samples: - self.assertEqual(append_content_to_html(html,content,flag), expected, 'append_content_to_html is broken') + self.assertEqual(misc.append_content_to_html(html,content,flag), expected, 'append_content_to_html is broken') + +class test_countingstream(unittest2.TestCase): + def test_empty_stream(self): + s = misc.CountingStream(iter([])) + self.assertEqual(s.index, -1) + self.assertIsNone(next(s, None)) + self.assertEqual(s.index, 0) + + def test_single(self): + s = misc.CountingStream(xrange(1)) + self.assertEqual(s.index, -1) + self.assertEqual(next(s, None), 0) + self.assertIsNone(next(s, None)) + self.assertEqual(s.index, 1) + + def test_full(self): + s = misc.CountingStream(xrange(42)) + for _ in s: + pass + self.assertEqual(s.index, 42) + + def test_repeated(self): + """ Once the CountingStream has stopped iterating, the index should not + increase anymore (the internal state should not be allowed to change) + """ + s = misc.CountingStream(iter([])) + self.assertIsNone(next(s, None)) + self.assertEqual(s.index, 0) + self.assertIsNone(next(s, None)) + self.assertEqual(s.index, 0) if __name__ == '__main__': - unittest2.main() \ No newline at end of file + unittest2.main() diff --git a/openerp/tools/misc.py b/openerp/tools/misc.py index 9a7b3499425..1ee3caf98d9 100644 --- a/openerp/tools/misc.py +++ b/openerp/tools/misc.py @@ -1220,4 +1220,38 @@ class mute_logger(object): with self: return func(*args, **kwargs) return deco + +_ph = object() +class CountingStream(object): + """ Stream wrapper counting the number of element it has yielded. Similar + role to ``enumerate``, but for use when the iteration process of the stream + isn't fully under caller control (the stream can be iterated from multiple + points including within a library) + + ``start`` allows overriding the starting index (the index before the first + item is returned). + + On each iteration (call to :meth:`~.next`), increases its :attr:`~.index` + by one. + + .. attribute:: index + + ``int``, index of the last yielded element in the stream. If the stream + has ended, will give an index 1-past the stream + """ + def __init__(self, stream, start=-1): + self.stream = iter(stream) + self.index = start + self.stopped = False + def __iter__(self): + return self + def next(self): + if self.stopped: raise StopIteration() + self.index += 1 + val = next(self.stream, _ph) + if val is _ph: + self.stopped = True + raise StopIteration() + return val + # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/setup.py b/setup.py index a26b74e255d..4451bc1b710 100755 --- a/setup.py +++ b/setup.py @@ -116,6 +116,7 @@ setuptools.setup( extras_require = { 'SSL' : ['pyopenssl'], }, + tests_require = ['unittest2'], **py2exe_options() ) From c9e0cfd64aa064df9a2544597bf77579ac4e08da Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 20 Sep 2012 12:25:45 +0200 Subject: [PATCH 02/43] [ADD] force linking to existing o2m being updated bzr revid: xmo@openerp.com-20120920102545-30tkodb4s1dng5hp --- openerp/addons/base/ir/ir_fields.py | 34 ++++++++++++++-- .../addons/test_impex/tests/test_load.py | 39 +++++++------------ 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index f73067c9f6c..6b3898f3fdd 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -5,6 +5,22 @@ import warnings from openerp.osv import orm, fields from openerp.tools.translate import _ +REFERENCING_FIELDS = set([None, 'id', '.id']) +def only_ref_fields(record): + return dict((k, v) for k, v in record.iteritems() + if k in REFERENCING_FIELDS) +def exclude_ref_fields(record): + return dict((k, v) for k, v in record.iteritems() + if k not in REFERENCING_FIELDS) + +CREATE = lambda values: (0, False, values) +UPDATE = lambda id, values: (1, id, values) +DELETE = lambda id: (2, id, False) +FORGET = lambda id: (3, id, False) +LINK_TO = lambda id: (4, id, False) +DELETE_ALL = lambda: (5, False, False) +REPLACE_WITH = lambda ids: (6, False, ids) + class ConversionNotFound(ValueError): pass class ir_fields_converter(orm.Model): @@ -145,9 +161,8 @@ class ir_fields_converter(orm.Model): :rtype: str """ # Can import by name_get, external id or database id - allowed_fields = set([None, 'id', '.id']) fieldset = set(record.iterkeys()) - if fieldset - allowed_fields: + if fieldset - REFERENCING_FIELDS: raise ValueError( _(u"Can not create Many-To-One records indirectly, import the field separately")) if len(fieldset) > 1: @@ -190,4 +205,17 @@ class ir_fields_converter(orm.Model): return [(6, 0, ids)] def _str_to_one2many(self, cr, uid, model, column, value, context=None): - return value + commands = [] + for subfield, record in zip((self._referencing_subfield( + only_ref_fields(record)) + for record in value), + value): + id, subfield_type = self.db_id_for( + cr, uid, model, column, subfield, record[subfield], context=context) + writable = exclude_ref_fields(record) + if id: + commands.append(LINK_TO(id)) + commands.append(UPDATE(id, writable)) + else: + commands.append(CREATE(writable)) + return commands diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 80e5cfe9c8f..44b6c1a56df 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -869,10 +869,13 @@ class test_o2m(ImporterCase): "invalid literal for int() with base 10: '%d,%d'" % (id1, id2)) def test_link(self): - id1 = self.registry('export.one2many.child').create(self.cr, openerp.SUPERUSER_ID, { + """ O2M relating to an existing record (update) force a LINK_TO as well + """ + O2M = self.registry('export.one2many.child') + id1 = O2M.create(self.cr, openerp.SUPERUSER_ID, { 'str': 'Bf', 'value': 109 }) - id2 = self.registry('export.one2many.child').create(self.cr, openerp.SUPERUSER_ID, { + id2 = O2M.create(self.cr, openerp.SUPERUSER_ID, { 'str': 'Me', 'value': 262 }) @@ -880,17 +883,14 @@ class test_o2m(ImporterCase): ['42', str(id1)], ['', str(id2)], ]) - self.assertEqual(len(ids), 2) self.assertFalse(messages) + self.assertEqual(len(ids), 1) - # No record values alongside id => o2m resolution skipped altogether, - # creates 2 records => remove/don't import columns sideshow columns, - # get completely different semantics - b, b1 = self.browse() + [b] = self.browse() self.assertEqual(b.const, 42) - self.assertEqual(values(b.value), []) - self.assertEqual(b1.const, 4) - self.assertEqual(values(b1.value), []) + # automatically forces link between core record and o2ms + self.assertEqual(values(b.value), [109, 262]) + self.assertEqual(values(b.value, field='parent_id'), [b, b]) def test_link_2(self): O2M_c = self.registry('export.one2many.child') @@ -905,24 +905,13 @@ class test_o2m(ImporterCase): ['42', str(id1), '1'], ['', str(id2), '2'], ]) - self.assertEqual(len(ids), 2) self.assertFalse(messages) + self.assertEqual(len(ids), 1) - (b,) = self.browse() - # if an id (db or xid) is provided, expectations that objects are - # *already* linked and emits UPDATE (1, id, {}). - # Noid => CREATE (0, ?, {}) - # TODO: xid ignored aside from getting corresponding db id? + [b] = self.browse() self.assertEqual(b.const, 42) - self.assertEqual(values(b.value), []) - - # FIXME: updates somebody else's records? - self.assertEqual( - O2M_c.read(self.cr, openerp.SUPERUSER_ID, id1), - {'id': id1, 'str': 'Bf', 'value': 1, 'parent_id': False}) - self.assertEqual( - O2M_c.read(self.cr, openerp.SUPERUSER_ID, id2), - {'id': id2, 'str': 'Me', 'value': 2, 'parent_id': False}) + self.assertEqual(values(b.value), [1, 2]) + self.assertEqual(values(b.value, field='parent_id'), [b, b]) class test_o2m_multiple(ImporterCase): model_name = 'export.one2many.multiple' From 8e841cd8f7603fc59be78b7d73283f65e4247840 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 20 Sep 2012 12:56:12 +0200 Subject: [PATCH 03/43] [FIX] non-linking o2m tests, corresponding code bzr revid: xmo@openerp.com-20120920105612-03ifizt2iv08tdhz --- openerp/addons/base/ir/ir_fields.py | 22 +++++--- openerp/tests/addons/test_impex/models.py | 6 +++ .../addons/test_impex/tests/test_load.py | 53 ++++++++----------- 3 files changed, 45 insertions(+), 36 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 6b3898f3fdd..44351229c26 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -206,16 +206,26 @@ class ir_fields_converter(orm.Model): return [(6, 0, ids)] def _str_to_one2many(self, cr, uid, model, column, value, context=None): commands = [] - for subfield, record in zip((self._referencing_subfield( - only_ref_fields(record)) - for record in value), - value): - id, subfield_type = self.db_id_for( - cr, uid, model, column, subfield, record[subfield], context=context) + + for record in value: + id = None + refs = only_ref_fields(record) + # there are ref fields in the record + if refs: + subfield = self._referencing_subfield(refs) + reference = record[subfield] + id, subfield_type = self.db_id_for( + cr, uid, model, column, subfield, reference, context=context) + if id is None: + raise ValueError( + _(u"No matching record found for %(field_type)s '%(value)s' in field '%%(field)s'") + % {'field_type': subfield_type, 'value': reference}) + writable = exclude_ref_fields(record) if id: commands.append(LINK_TO(id)) commands.append(UPDATE(id, writable)) else: commands.append(CREATE(writable)) + return commands diff --git a/openerp/tests/addons/test_impex/models.py b/openerp/tests/addons/test_impex/models.py index 455ea6b22f4..37894ccb78c 100644 --- a/openerp/tests/addons/test_impex/models.py +++ b/openerp/tests/addons/test_impex/models.py @@ -67,6 +67,12 @@ class One2ManyChild(orm.Model): def name_get(self, cr, uid, ids, context=None): return [(record.id, "%s:%s" % (self._name, record.value)) for record in self.browse(cr, uid, ids, context=context)] + def name_search(self, cr, user, name='', args=None, operator='ilike', context=None, limit=100): + return (self.name_get(cr, user, + self.search(cr, user, [['value', operator, int(name.split(':')[1])]]) + , context=context) + if isinstance(name, basestring) and name.split(':')[0] == self._name + else []) class One2ManyMultiple(orm.Model): _name = 'export.one2many.multiple' diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 44b6c1a56df..25159dcd2ba 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -786,20 +786,21 @@ class test_o2m(ImporterCase): model_name = 'export.one2many' def test_name_get(self): - # FIXME: bloody hell why can't this just name_create the record? - self.assertRaises( - IndexError, - self.import_, + s = u'Java is a DSL for taking large XML files and converting them ' \ + u'to stack traces' + ids, messages = self.import_( ['const', 'value'], - [['5', u'Java is a DSL for taking large XML files' - u' and converting them to stack traces']]) + [['5', s]]) + self.assertEqual(messages, [message( + u"No matching record found for name '%s' in field 'value'" % s)]) + self.assertIs(ids, False) def test_single(self): ids, messages = self.import_(['const', 'value/value'], [ ['5', '63'] ]) - self.assertEqual(len(ids), 1) self.assertFalse(messages) + self.assertEqual(len(ids), 1) (b,) = self.browse() self.assertEqual(b.const, 5) @@ -810,8 +811,8 @@ class test_o2m(ImporterCase): ['5', '63'], ['6', '64'], ]) - self.assertEqual(len(ids), 2) self.assertFalse(messages) + self.assertEqual(len(ids), 2) b1, b2 = self.browse() self.assertEqual(b1.const, 5) @@ -826,8 +827,8 @@ class test_o2m(ImporterCase): ['', '65'], ['', '66'], ]) - self.assertEqual(len(ids), 4) self.assertFalse(messages) + self.assertEqual(len(ids), 1) (b,) = self.browse() self.assertEqual(values(b.value), [63, 64, 65, 66]) @@ -839,8 +840,8 @@ class test_o2m(ImporterCase): ['the', '', '65'], ['rhythm', '', '66'], ]) - self.assertEqual(len(ids), 4) self.assertFalse(messages) + self.assertEqual(len(ids), 1) (b,) = self.browse() self.assertEqual(values(b.value), [63, 64, 65, 66]) @@ -923,18 +924,12 @@ class test_o2m_multiple(ImporterCase): ['', '13', '23'], ['', '14', ''], ]) - self.assertEqual(len(ids), 4) self.assertFalse(messages) + self.assertEqual(len(ids), 1) # Oh yeah, that's the stuff - (b, b1, b2) = self.browse() - self.assertEqual(values(b.child1), [11]) - self.assertEqual(values(b.child2), [21]) - - self.assertEqual(values(b1.child1), [12]) - self.assertEqual(values(b1.child2), [22]) - - self.assertEqual(values(b2.child1), [13, 14]) - self.assertEqual(values(b2.child2), [23]) + [b] = self.browse() + self.assertEqual(values(b.child1), [11, 12, 13, 14]) + self.assertEqual(values(b.child2), [21, 22, 23]) def test_multi(self): ids, messages = self.import_(['const', 'child1/value', 'child2/value'], [ @@ -945,13 +940,12 @@ class test_o2m_multiple(ImporterCase): ['', '', '22'], ['', '', '23'], ]) - self.assertEqual(len(ids), 6) self.assertFalse(messages) - # What the actual fuck? - (b, b1) = self.browse() + self.assertEqual(len(ids), 1) + + [b] = self.browse() self.assertEqual(values(b.child1), [11, 12, 13, 14]) - self.assertEqual(values(b.child2), [21]) - self.assertEqual(values(b1.child2), [22, 23]) + self.assertEqual(values(b.child2), [21, 22, 23]) def test_multi_fullsplit(self): ids, messages = self.import_(['const', 'child1/value', 'child2/value'], [ @@ -963,14 +957,13 @@ class test_o2m_multiple(ImporterCase): ['', '', '22'], ['', '', '23'], ]) - self.assertEqual(len(ids), 7) self.assertFalse(messages) - # oh wow - (b, b1) = self.browse() + self.assertEqual(len(ids), 1) + + [b] = self.browse() self.assertEqual(b.const, 5) self.assertEqual(values(b.child1), [11, 12, 13, 14]) - self.assertEqual(b1.const, 36) - self.assertEqual(values(b1.child2), [21, 22, 23]) + self.assertEqual(values(b.child2), [21, 22, 23]) # function, related, reference: written to db as-is... # => function uses @type for value coercion/conversion From fdba99aaeb3998b6f1e76e69fc77bf45b0c73ee2 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 20 Sep 2012 13:09:14 +0200 Subject: [PATCH 04/43] [ADD] inline o2m LINK_TO (m2m-style) bzr revid: xmo@openerp.com-20120920110914-hy2rtivhn9cs5wuc --- openerp/addons/base/ir/ir_fields.py | 13 ++++++++-- .../addons/test_impex/tests/test_load.py | 24 ++++++++++--------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 44351229c26..ae0d33298ca 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -204,10 +204,19 @@ class ir_fields_converter(orm.Model): ids.append(id) return [(6, 0, ids)] - def _str_to_one2many(self, cr, uid, model, column, value, context=None): + def _str_to_one2many(self, cr, uid, model, column, records, context=None): commands = [] - for record in value: + if len(records) == 1 and exclude_ref_fields(records[0]) == {}: + # only one row with only ref field, field=ref1,ref2,ref3 as in + # m2o/m2m + record = records[0] + subfield = self._referencing_subfield(record) + # transform [{subfield:ref1,ref2,ref3}] into + # [{subfield:ref1},{subfield:ref2},{subfield:ref3}] + records = ({subfield:item} for item in record[subfield].split(',')) + + for record in records: id = None refs = only_ref_fields(record) # there are ref fields in the record diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 25159dcd2ba..9ce0d5628ed 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -850,6 +850,8 @@ class test_o2m(ImporterCase): 'this is the rhythm'.split()) def test_link_inline(self): + """ m2m-style specification for o2ms + """ id1 = self.registry('export.one2many.child').create(self.cr, openerp.SUPERUSER_ID, { 'str': 'Bf', 'value': 109 }) @@ -857,17 +859,17 @@ class test_o2m(ImporterCase): 'str': 'Me', 'value': 262 }) - try: - self.import_(['const', 'value/.id'], [ - ['42', '%d,%d' % (id1, id2)] - ]) - self.fail("Should have raised a valueerror") - except ValueError, e: - # should be Exception(Database ID doesn't exist: export.one2many.child : $id1,$id2) - self.assertIs(type(e), ValueError) - self.assertEqual( - e.args[0], - "invalid literal for int() with base 10: '%d,%d'" % (id1, id2)) + ids, messages = self.import_(['const', 'value/.id'], [ + ['42', '%d,%d' % (id1, id2)] + ]) + self.assertFalse(messages) + self.assertEqual(len(ids), 1) + + [b] = self.browse() + self.assertEqual(b.const, 42) + # automatically forces link between core record and o2ms + self.assertEqual(values(b.value), [109, 262]) + self.assertEqual(values(b.value, field='parent_id'), [b, b]) def test_link(self): """ O2M relating to an existing record (update) force a LINK_TO as well From a9bc82c46dc007fa3bf0d479d6afab40530bb648 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 20 Sep 2012 17:04:43 +0200 Subject: [PATCH 05/43] [IMP] translation tests, translated acceptable values for boolean fields bzr revid: xmo@openerp.com-20120920150443-l9lna4bnkta7n2o8 --- openerp/addons/base/ir/ir_fields.py | 27 +++- .../addons/test_impex/tests/test_load.py | 144 ++++++++---------- 2 files changed, 90 insertions(+), 81 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index ae0d33298ca..10b80511c73 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import functools import operator +import itertools import warnings from openerp.osv import orm, fields from openerp.tools.translate import _ @@ -55,7 +56,29 @@ class ir_fields_converter(orm.Model): converter, cr, uid, model, column, context=context) def _str_to_boolean(self, cr, uid, model, column, value, context=None): - return value.lower() not in ('', '0', 'false', 'off') + # all translatables used for booleans + true, yes, false, no = _(u"true"), _(u"yes"), _(u"false"), _(u"no") + # potentially broken casefolding? What about locales? + trues = set(word.lower() for word in itertools.chain( + [u'1', u"true", u"yes"], # don't use potentially translated values + self._get_translations(cr, uid, ['code'], u"true", context=context), + self._get_translations(cr, uid, ['code'], u"yes", context=context), + )) + if value.lower() in trues: return True + + # potentially broken casefolding? What about locales? + falses = set(word.lower() for word in itertools.chain( + [u'', u"0", u"false", u"no"], + self._get_translations(cr, uid, ['code'], u"false", context=context), + self._get_translations(cr, uid, ['code'], u"no", context=context), + )) + if value.lower() in falses: return False + + warnings.warn( + _(u"Unknown value '%s' for boolean field '%%(field)s', assuming '%s'") + % (value, yes), + orm.ImportWarning) + return True def _str_to_integer(self, cr, uid, model, column, value, context=None): if not value: return False @@ -86,7 +109,7 @@ class ir_fields_converter(orm.Model): selection = selection(model, cr, uid) for item, label in selection: labels = self._get_translations( - cr, uid, ('selection', 'model'), label, context=context) + cr, uid, ('selection', 'model', 'code'), label, context=context) labels.append(label) if value == unicode(item) or value in labels: return item diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 9ce0d5628ed..28957c29555 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -79,6 +79,27 @@ class ImporterCase(common.TransactionCase): }) return '__test__.' + name + def add_translations(self, name, type, code, *tnx): + Lang = self.registry('res.lang') + if not Lang.search(self.cr, openerp.SUPERUSER_ID, [('code', '=', code)]): + Lang.create(self.cr, openerp.SUPERUSER_ID, { + 'name': code, + 'code': code, + 'translatable': True, + 'date_format': '%d.%m.%Y', + 'decimal_point': ',', + }) + Translations = self.registry('ir.translation') + for source, value in tnx: + Translations.create(self.cr, openerp.SUPERUSER_ID, { + 'name': name, + 'lang': code, + 'type': type, + 'src': source, + 'value': value, + 'state': 'translated', + }) + class test_ids_stuff(ImporterCase): model_name = 'export.integer' @@ -139,41 +160,39 @@ class test_boolean_field(ImporterCase): ], values(records)) def test_falses(self): - ids, messages = self.import_( - ['value'], - [[u'0'], [u'off'], - [u'false'], [u'FALSE'], - [u'OFF'], [u''], - ]) - self.assertEqual(len(ids), 6) + for lang, source, value in [('fr_FR', 'no', u'non'), + ('de_DE', 'no', u'nein'), + ('ru_RU', 'no', u'нет'), + ('nl_BE', 'false', u'vals'), + ('lt_LT', 'false', u'klaidingas')]: + self.add_translations('test_import.py', 'code', lang, (source, value)) + falses = [[u'0'], [u'no'], [u'false'], [u'FALSE'], [u''], + [u'non'], # no, fr + [u'nein'], # no, de + [u'нет'], # no, ru + [u'vals'], # false, nl + [u'klaidingas'], # false, lt, + ] + + ids, messages = self.import_(['value'], falses) self.assertFalse(messages) - self.assertEqual([ - False, - False, - False, - False, - False, - False, - ], - values(self.read())) + self.assertEqual(len(ids), len(falses)) + self.assertEqual([False] * len(falses), values(self.read())) def test_trues(self): - ids, messages = self.import_( - ['value'], - [['no'], - ['None'], - ['nil'], - ['()'], - ['f'], - ['#f'], - # Problem: OpenOffice (and probably excel) output localized booleans - ['VRAI'], + trues = [['None'], ['nil'], ['()'], ['f'], ['#f'], + # Problem: OpenOffice (and probably excel) output localized booleans + ['VRAI'], ['ok'], ['true'], ['yes'], ['1'], ] + ids, messages = self.import_(['value'], trues) + self.assertEqual(len(ids), 10) + self.assertEqual(messages, [ + message(u"Unknown value '%s' for boolean field 'value', assuming 'yes'" % v[0], + type='warning', from_=i, to_=i, record=i) + for i, v in enumerate(trues) + if v[0] != 'true' if v[0] != 'yes' if v[0] != '1' ]) - self.assertEqual(len(ids), 7) - # FIXME: should warn for values which are not "true", "yes" or "1" - self.assertFalse(messages) self.assertEqual( - [True] * 7, + [True] * 10, values(self.read())) class test_integer_field(ImporterCase): @@ -399,9 +418,9 @@ class test_text(ImporterCase): class test_selection(ImporterCase): model_name = 'export.selection' translations_fr = [ - ("Qux", "toto"), - ("Bar", "titi"), ("Foo", "tete"), + ("Bar", "titi"), + ("Qux", "toto"), ] def test_imported(self): @@ -416,23 +435,8 @@ class test_selection(ImporterCase): self.assertEqual([3, 2, 1, 2], values(self.read())) def test_imported_translated(self): - self.registry('res.lang').create(self.cr, openerp.SUPERUSER_ID, { - 'name': u'Français', - 'code': 'fr_FR', - 'translatable': True, - 'date_format': '%d.%m.%Y', - 'decimal_point': ',', - 'thousand_sep': ' ', - }) - Translations = self.registry('ir.translation') - for source, value in self.translations_fr: - Translations.create(self.cr, openerp.SUPERUSER_ID, { - 'name': 'export.selection,value', - 'lang': 'fr_FR', - 'type': 'selection', - 'src': source, - 'value': value - }) + self.add_translations( + 'export.selection,value', 'selection', 'fr_FR', *self.translations_fr) ids, messages = self.import_(['value'], [ ['toto'], @@ -474,7 +478,7 @@ class test_selection_function(ImporterCase): translations_fr = [ ("Corge", "toto"), ("Grault", "titi"), - ("Whee", "tete"), + ("Wheee", "tete"), ("Moog", "tutu"), ] @@ -483,7 +487,7 @@ class test_selection_function(ImporterCase): be good news) *and* serializes the selection function to reverse it: import does not actually know that the selection field uses a function """ - # NOTE: conflict between a value and a label => ? + # NOTE: conflict between a value and a label => pick first ids, messages = self.import_(['value'], [ ['3'], ["Grault"], @@ -497,38 +501,20 @@ class test_selection_function(ImporterCase): def test_translated(self): """ Expects output of selection function returns translated labels """ - self.registry('res.lang').create(self.cr, openerp.SUPERUSER_ID, { - 'name': u'Français', - 'code': 'fr_FR', - 'translatable': True, - 'date_format': '%d.%m.%Y', - 'decimal_point': ',', - 'thousand_sep': ' ', - }) - Translations = self.registry('ir.translation') - for source, value in self.translations_fr: - Translations.create(self.cr, openerp.SUPERUSER_ID, { - 'name': 'export.selection,value', - 'lang': 'fr_FR', - 'type': 'selection', - 'src': source, - 'value': value - }) + self.add_translations( + 'export.selection,value', 'selection', 'fr_FR', *self.translations_fr) + ids, messages = self.import_(['value'], [ - ['toto'], + ['titi'], ['tete'], ], context={'lang': 'fr_FR'}) - self.assertIs(ids, False) - self.assertEqual(messages, [{ - 'type': 'error', - 'rows': {'from': 1, 'to': 1}, - 'record': 1, - 'field': 'value', - 'message': "Value 'tete' not found in selection field 'value'", - }]) - ids, messages = self.import_(['value'], [['Wheee']], context={'lang': 'fr_FR'}) - self.assertEqual(len(ids), 1) self.assertFalse(messages) + self.assertEqual(len(ids), 2) + self.assertEqual(values(self.read()), ['1', '2']) + + ids, messages = self.import_(['value'], [['Wheee']], context={'lang': 'fr_FR'}) + self.assertFalse(messages) + self.assertEqual(len(ids), 1) class test_m2o(ImporterCase): model_name = 'export.many2one' From b4421c8fba5a7208a0cf38708cfaa235d9bfa497 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 24 Sep 2012 12:14:04 +0200 Subject: [PATCH 06/43] [ADD] cache dict on the cursor, for request-local repeated reads bzr revid: xmo@openerp.com-20120924101404-cel6oy1akabbo7id --- openerp/sql_db.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openerp/sql_db.py b/openerp/sql_db.py index 7b6f4469b8d..a6ebcd0300f 100644 --- a/openerp/sql_db.py +++ b/openerp/sql_db.py @@ -138,6 +138,16 @@ class Cursor(object): sure you use psycopg2 v2.4.2 or newer if you use PostgreSQL 9.1 and the performance hit is a concern for you. + .. attribute:: cache + + Cache dictionary with a "request" (-ish) lifecycle, only lives as + long as the cursor itself does and proactively cleared when the + cursor is closed. + + This cache should *only* be used to store repeatable reads as it + ignores rollbacks and savepoints, it should not be used to store + *any* data which may be modified during the life of the cursor. + """ IN_MAX = 1000 # decent limit on size of IN queries - guideline = Oracle limit @@ -182,6 +192,8 @@ class Cursor(object): self._default_log_exceptions = True + self.cache = {} + def __del__(self): if not self.__closed and not self._cnx.closed: # Oops. 'self' has not been closed explicitly. @@ -279,6 +291,8 @@ class Cursor(object): if not self._obj: return + del self.cache + if self.sql_log: self.__closer = frame_codeinfo(currentframe(),3) self.print_log() From 449a86a51f62c2e3379fc649c55d89080c278fa4 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 24 Sep 2012 12:32:57 +0200 Subject: [PATCH 07/43] [IMP] cache boolean and selection translations for the request (cursor) to avoid fetching them over and over again bzr revid: xmo@openerp.com-20120924103257-1jgc3qhddzzi5c17 --- openerp/addons/base/ir/ir_fields.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 10b80511c73..73e748015a3 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -95,11 +95,20 @@ class ir_fields_converter(orm.Model): return value or False def _get_translations(self, cr, uid, types, src, context): + types = tuple(types) + # Cache translations so they don't have to be reloaded from scratch on + # every row of the file + tnx_cache = cr.cache.setdefault(self._name, {}) + if tnx_cache.setdefault(types, {}) and src in tnx_cache[types]: + return tnx_cache[types][src] + Translations = self.pool['ir.translation'] tnx_ids = Translations.search( cr, uid, [('type', 'in', types), ('src', '=', src)], context=context) tnx = Translations.read(cr, uid, tnx_ids, ['value'], context=context) - return map(operator.itemgetter('value'), tnx) + result = tnx_cache[types][src] = map(operator.itemgetter('value'), tnx) + return result + def _str_to_selection(self, cr, uid, model, column, value, context=None): selection = column.selection From 877e21ffdebeba412b7cb4db5fdf352adfac2ff2 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 24 Sep 2012 12:52:30 +0200 Subject: [PATCH 08/43] [IMP] return fields_get-style translated field strings (if available) in user-readable warning and error messages from import, rather than logical field names bzr revid: xmo@openerp.com-20120924105230-1b7157xbruy2e5zr --- openerp/osv/orm.py | 19 +++++++++++--- .../addons/test_impex/tests/test_load.py | 26 +++++++++---------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index 57cc5823dd9..e58d6b76a76 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1585,8 +1585,15 @@ class BaseModel(object): :returns: a list of triplets of (id, xid, record) :rtype: list((int|None, str|None, dict)) """ + if context is None: context = {} Converter = self.pool['ir.fields.converter'] columns = dict((k, v.column) for k, v in self._all_columns.iteritems()) + Translation = self.pool['ir.translation'] + field_names = dict( + (f, (Translation._get_source(cr, uid, self._name + ',' + f, 'field', + context.get('lang', False) or 'en_US') + or column.string or f)) + for f, column in columns.iteritems()) converters = dict( (k, Converter.to_field(cr, uid, self, column, context=context)) for k, column in columns.iteritems()) @@ -1619,18 +1626,24 @@ class BaseModel(object): for field, strvalue in record.iteritems(): if field in (None, 'id', '.id'): continue - message_base = dict(extras, record=stream.index, field=field) + # In warnings and error messages, use translated string as + # field name + message_base = dict( + extras, record=stream.index, field=field_names[field]) with warnings.catch_warnings(record=True) as w: try: converted[field] = converters[field](strvalue) + # In warning and error returned, use logical field name + # as field so client can reverse for warning in w: - log(dict(message_base, type='warning', + log(dict(message_base, type='warning', field=field, message=unicode(warning.message) % message_base)) except ValueError, e: log(dict(message_base, type='error', - message=unicode(e) % message_base + field=field, + message=unicode(e) % message_base, )) yield dbid, xid, converted, dict(extras, record=stream.index) diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 28957c29555..cf95f5bb6c4 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -186,7 +186,7 @@ class test_boolean_field(ImporterCase): ids, messages = self.import_(['value'], trues) self.assertEqual(len(ids), 10) self.assertEqual(messages, [ - message(u"Unknown value '%s' for boolean field 'value', assuming 'yes'" % v[0], + message(u"Unknown value '%s' for boolean field 'unknown', assuming 'yes'" % v[0], type='warning', from_=i, to_=i, record=i) for i, v in enumerate(trues) if v[0] != 'true' if v[0] != 'yes' if v[0] != '1' @@ -460,7 +460,7 @@ class test_selection(ImporterCase): 'rows': {'from': 0, 'to': 0}, 'record': 0, 'field': 'value', - 'message': "Value 'Baz' not found in selection field 'value'", + 'message': "Value 'Baz' not found in selection field 'unknown'", }]) ids, messages = self.import_(['value'], [[42]]) @@ -470,7 +470,7 @@ class test_selection(ImporterCase): 'rows': {'from': 0, 'to': 0}, 'record': 0, 'field': 'value', - 'message': "Value '42' not found in selection field 'value'", + 'message': "Value '42' not found in selection field 'unknown'", }]) class test_selection_function(ImporterCase): @@ -583,7 +583,7 @@ class test_m2o(ImporterCase): ids, messages = self.import_(['value'], [[name2]]) self.assertEqual( messages, - [message(u"Found multiple matches for field 'value' (2 matches)", + [message(u"Found multiple matches for field 'unknown' (2 matches)", type='warning')]) self.assertEqual(len(ids), 1) self.assertEqual([ @@ -607,7 +607,7 @@ class test_m2o(ImporterCase): [integer_id1], ]) self.assertEqual(messages, [ - message(u"No matching record found for name '%s' in field 'value'" % id, + message(u"No matching record found for name '%s' in field 'unknown'" % id, from_=index, to_=index, record=index) for index, id in enumerate([integer_id1, integer_id2, integer_id1])]) self.assertIs(ids, False) @@ -626,19 +626,19 @@ class test_m2o(ImporterCase): ids, messages = self.import_(['value'], [['nameisnoexist:3']]) self.assertEqual(messages, [message( u"No matching record found for name 'nameisnoexist:3' " - u"in field 'value'")]) + u"in field 'unknown'")]) self.assertIs(ids, False) ids, messages = self.import_(['value/id'], [['noxidhere']]) self.assertEqual(messages, [message( u"No matching record found for external id 'noxidhere' " - u"in field 'value'")]) + u"in field 'unknown'")]) self.assertIs(ids, False) ids, messages = self.import_(['value/.id'], [['66']]) self.assertEqual(messages, [message( u"No matching record found for database id '66' " - u"in field 'value'")]) + u"in field 'unknown'")]) self.assertIs(ids, False) def test_fail_multiple(self): @@ -646,7 +646,7 @@ class test_m2o(ImporterCase): ['value', 'value/id'], [['somename', 'somexid']]) self.assertEqual(messages, [message( - u"Ambiguous specification for field 'value', only provide one of " + u"Ambiguous specification for field 'unknown', only provide one of " u"name, external id or database id")]) self.assertIs(ids, False) @@ -690,7 +690,7 @@ class test_m2m(ImporterCase): ids, messages = self.import_(['value/.id'], [['42']]) self.assertEqual(messages, [message( u"No matching record found for database id '42' in field " - u"'value'")]) + u"'unknown'")]) self.assertIs(ids, False) def test_xids(self): @@ -716,7 +716,7 @@ class test_m2m(ImporterCase): ids, messages = self.import_(['value/id'], [['noxidforthat']]) self.assertEqual(messages, [message( u"No matching record found for external id 'noxidforthat' " - u"in field 'value'")]) + u"in field 'unknown'")]) self.assertIs(ids, False) def test_names(self): @@ -745,7 +745,7 @@ class test_m2m(ImporterCase): ids, messages = self.import_(['value'], [['wherethem2mhavenonames']]) self.assertEqual(messages, [message( u"No matching record found for name 'wherethem2mhavenonames' in " - u"field 'value'")]) + u"field 'unknown'")]) self.assertIs(ids, False) def test_import_to_existing(self): @@ -778,7 +778,7 @@ class test_o2m(ImporterCase): ['const', 'value'], [['5', s]]) self.assertEqual(messages, [message( - u"No matching record found for name '%s' in field 'value'" % s)]) + u"No matching record found for name '%s' in field 'unknown'" % s)]) self.assertIs(ids, False) def test_single(self): From a9c2cfcdb91c79cc60de909a4e23bd9e9e5f9787 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 24 Sep 2012 16:57:50 +0200 Subject: [PATCH 09/43] [IMP] make help attribute available on all actions bzr revid: xmo@openerp.com-20120924145750-n0gj4bww1d83h3fy --- openerp/addons/base/ir/ir_actions.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/openerp/addons/base/ir/ir_actions.py b/openerp/addons/base/ir/ir_actions.py index 383f78181e9..b14f4cd7e84 100644 --- a/openerp/addons/base/ir/ir_actions.py +++ b/openerp/addons/base/ir/ir_actions.py @@ -19,14 +19,11 @@ # ############################################################################## -import ast -import copy import logging import os import re import time import tools -from xml import dom import netsvc from osv import fields,osv @@ -47,6 +44,9 @@ class actions(osv.osv): 'name': fields.char('Name', size=64, required=True), 'type': fields.char('Action Type', required=True, size=32,readonly=True), 'usage': fields.char('Action Usage', size=32), + 'help': fields.text('Action description', + help='Optional help text for the users with a description of the target view, such as its usage and purpose.', + translate=True), } _defaults = { 'usage': lambda *a: False, @@ -107,6 +107,7 @@ class report_xml(osv.osv): r['report_xsl'] and opj('addons',r['report_xsl'])) _name = 'ir.actions.report.xml' + _inherit = 'ir.actions.actions' _table = 'ir_act_report_xml' _sequence = 'ir_actions_id_seq' _order = 'name' @@ -155,6 +156,7 @@ report_xml() class act_window(osv.osv): _name = 'ir.actions.act_window' _table = 'ir_act_window' + _inherit = 'ir.actions.actions' _sequence = 'ir_actions_id_seq' _order = 'name' @@ -245,9 +247,6 @@ class act_window(osv.osv): 'filter': fields.boolean('Filter'), 'auto_search':fields.boolean('Auto Search'), 'search_view' : fields.function(_search_view, type='text', string='Search View'), - 'help': fields.text('Action description', - help='Optional help text for the users with a description of the target view, such as its usage and purpose.', - translate=True), 'multi': fields.boolean('Action on Multiple Doc.', help="If set to true, the action will not be displayed on the right toolbar of a form view"), } @@ -331,6 +330,7 @@ act_wizard() class act_url(osv.osv): _name = 'ir.actions.url' _table = 'ir_act_url' + _inherit = 'ir.actions.actions' _sequence = 'ir_actions_id_seq' _order = 'name' _columns = { @@ -432,6 +432,7 @@ class actions_server(osv.osv): _name = 'ir.actions.server' _table = 'ir_act_server' + _inherit = 'ir.actions.actions' _sequence = 'ir_actions_id_seq' _order = 'sequence,name' _columns = { From f143902d1afa345c8fad935a80a6b404a9a3cfb7 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 24 Sep 2012 17:04:17 +0200 Subject: [PATCH 10/43] [IMP] allow converters to add data to import messages, formalize message keys bzr revid: xmo@openerp.com-20120924150417-c2y7g7vdsfz66363 --- openerp/addons/base/ir/ir_fields.py | 33 +++++++-- openerp/osv/orm.py | 70 +++++++++++++++---- .../addons/test_impex/tests/test_load.py | 32 +++------ 3 files changed, 94 insertions(+), 41 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 73e748015a3..2256542533b 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -38,6 +38,26 @@ class ir_fields_converter(orm.Model): By default, tries to get a method on itself with a name matching the pattern ``_$fromtype_$column._type`` and returns it. + Converter callables can either return a value to their caller or raise + ``ValueError``, which will be interpreted as a validation & conversion + failure. + + ValueError can have either one or two parameters. The first parameter + is mandatory, **must** be a unicode string and will be used as the + user-visible message for the error (it should be translatable and + translated). It can contain a ``field`` named format placeholder so the + caller can inject the field's translated, user-facing name (@string). + + The second parameter is optional and, if provided, must be a mapping. + This mapping will be merged into the error dictionary returned to the + client. + + If a converter can perform its function but has to make assumptions + about the data, it can send a warning to the user through signalling + an :class:`~openerp.osv.orm.ImportWarning` (via ``warnings.warn``). The + handling of a warning at the upper levels is the same as + ``ValueError`` above. + :param cr: openerp cursor :param uid: ID of user calling the converter :param column: column object to generate a value for @@ -74,10 +94,9 @@ class ir_fields_converter(orm.Model): )) if value.lower() in falses: return False - warnings.warn( + warnings.warn(orm.ImportWarning( _(u"Unknown value '%s' for boolean field '%%(field)s', assuming '%s'") - % (value, yes), - orm.ImportWarning) + % (value, yes))) return True def _str_to_integer(self, cr, uid, model, column, value, context=None): @@ -124,7 +143,9 @@ class ir_fields_converter(orm.Model): return item raise ValueError( _(u"Value '%s' not found in selection field '%%(field)s'") % ( - value)) + value), { + 'moreinfo': map(operator.itemgetter(1), selection) + }) def db_id_for(self, cr, uid, model, column, subfield, value, context=None): @@ -174,9 +195,9 @@ class ir_fields_converter(orm.Model): cr, uid, name=value, operator='=', context=context) if ids: if len(ids) > 1: - warnings.warn( + warnings.warn(orm.ImportWarning( _(u"Found multiple matches for field '%%(field)s' (%d matches)") - % (len(ids)), orm.ImportWarning) + % (len(ids)))) id, _name = ids[0] else: raise Exception(u"Unknown sub-field '%s'" % subfield) diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index e58d6b76a76..98e24075311 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1444,6 +1444,40 @@ class BaseModel(object): def load(self, cr, uid, fields, data, context=None): """ + Attempts to load the data matrix, and returns a list of ids (or + ``False`` if there was an error and no id could be generated) and a + list of messages. + + Each message is a dictionary with the following keys: + + ``type`` + the type of message, either ``warning`` or ``error``. Any ``error`` + message indicates the import failed and was rolled back. + ``message`` + the message's actual text, which should be translated and can be + shown to the user directly + ``rows`` + a dict with 2 keys ``from`` and ``to``, indicates the range of rows + in ``data`` which generated the message + ``record`` + a single integer, for warnings the index of the record which + generated the message (can be obtained from a non-false ``ids`` + result) + ``field`` + the name of the (logical) OpenERP field for which the error or + warning was generated + ``moreinfo`` (optional) + A string, a list or a dict, leading to more information about the + warning. + + * If ``moreinfo`` is a string, it is a supplementary warnings + message which should be hidden by default + * If ``moreinfo`` is a list, it provides a number of possible or + alternative values for the string + * If ``moreinfo`` is a dict, it is an OpenERP action descriptor + which can be executed to get more information about the issues + with the field. If present, the ``help`` key serves as a label + for the action (e.g. the text of the link). :param cr: cursor for the request :param int uid: ID of the user attempting the data import @@ -1598,6 +1632,14 @@ class BaseModel(object): (k, Converter.to_field(cr, uid, self, column, context=context)) for k, column in columns.iteritems()) + def _log(base, field, exception): + type = 'warning' if isinstance(exception, Warning) else 'error' + record = dict(base, field=field, type=type, + message=unicode(exception.args[0]) % base) + if len(exception.args) > 1 and exception.args[1]: + record.update(exception.args[1]) + log(record) + stream = CountingStream(records) for record, extras in stream: dbid = False @@ -1630,21 +1672,23 @@ class BaseModel(object): # field name message_base = dict( extras, record=stream.index, field=field_names[field]) - with warnings.catch_warnings(record=True) as w: - try: + try: + with warnings.catch_warnings(record=True) as ws: converted[field] = converters[field](strvalue) - # In warning and error returned, use logical field name - # as field so client can reverse - for warning in w: - log(dict(message_base, type='warning', field=field, - message=unicode(warning.message) % message_base)) - except ValueError, e: - log(dict(message_base, - type='error', - field=field, - message=unicode(e) % message_base, - )) + for warning in ws: + # bubble non-import warnings upward + if warning.category != ImportWarning: + warnings.warn(warning.message, warning.category) + continue + w = warning.message + if isinstance(w, basestring): + # wrap warning string in an ImportWarning for + # uniform handling + w = ImportWarning(w) + _log(message_base, field, w) + except ValueError, e: + _log(message_base, field, e) yield dbid, xid, converted, dict(extras, record=stream.index) diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index cf95f5bb6c4..f97dafaaf39 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -5,14 +5,10 @@ import openerp from openerp.tests import common from openerp.tools.misc import mute_logger -def message(msg, type='error', from_=0, to_=0, record=0, field='value'): - return { - 'type': type, - 'rows': {'from': from_, 'to': to_}, - 'record': record, - 'field': field, - 'message': msg - } +def message(msg, type='error', from_=0, to_=0, record=0, field='value', **kwargs): + return dict(kwargs, + type=type, rows={'from': from_, 'to': to_}, record=record, + field=field, message=msg) def error(row, message, record=None, **kwargs): """ Failed import of the record ``record`` at line ``row``, with the error @@ -455,23 +451,15 @@ class test_selection(ImporterCase): def test_invalid(self): ids, messages = self.import_(['value'], [['Baz']]) self.assertIs(ids, False) - self.assertEqual(messages, [{ - 'type': 'error', - 'rows': {'from': 0, 'to': 0}, - 'record': 0, - 'field': 'value', - 'message': "Value 'Baz' not found in selection field 'unknown'", - }]) + self.assertEqual(messages, [message( + u"Value 'Baz' not found in selection field 'unknown'", + moreinfo="Foo Bar Qux".split())]) ids, messages = self.import_(['value'], [[42]]) self.assertIs(ids, False) - self.assertEqual(messages, [{ - 'type': 'error', - 'rows': {'from': 0, 'to': 0}, - 'record': 0, - 'field': 'value', - 'message': "Value '42' not found in selection field 'unknown'", - }]) + self.assertEqual(messages, [message( + u"Value '42' not found in selection field 'unknown'", + moreinfo="Foo Bar Qux".split())]) class test_selection_function(ImporterCase): model_name = 'export.selection.function' From 9f2e7ba7ef86808ea5f10af178e7aeb939426323 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 24 Sep 2012 17:15:02 +0200 Subject: [PATCH 11/43] [IMP] return a dict from Model.load for easier future extensibility (if needed) rather than a tuple. also easier/cleaner to unpack on the JS side bzr revid: xmo@openerp.com-20120924151502-4robe639ctpuvb94 --- openerp/osv/orm.py | 6 +- .../addons/test_impex/tests/test_load.py | 378 +++++++++--------- 2 files changed, 192 insertions(+), 192 deletions(-) diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index 98e24075311..b824be91b96 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1486,7 +1486,7 @@ class BaseModel(object): :param data: row-major matrix of data to import :type data: list(list(str)) :param dict context: - :returns: + :returns: {ids: list(int)|False, messages: [Message]} """ cr.execute('SAVEPOINT model_load') messages = [] @@ -1516,8 +1516,8 @@ class BaseModel(object): messages.append(dict(info, type="error", message=str(e))) if any(message['type'] == 'error' for message in messages): cr.execute('ROLLBACK TO SAVEPOINT model_load') - return False, messages - return ids, messages + ids = False + return {'ids': ids, 'messages': messages} def _extract_records(self, cr, uid, fields_, data, context=None, log=lambda a: None): """ Generates record dicts from the data iterable. diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index f97dafaaf39..060b32c48f5 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -100,9 +100,9 @@ class test_ids_stuff(ImporterCase): model_name = 'export.integer' def test_create_with_id(self): - ids, messages = self.import_(['.id', 'value'], [['42', '36']]) - self.assertIs(ids, False) - self.assertEqual(messages, [{ + result = self.import_(['.id', 'value'], [['42', '36']]) + self.assertIs(result['ids'], False) + self.assertEqual(result['messages'], [{ 'type': 'error', 'rows': {'from': 0, 'to': 0}, 'record': 0, @@ -110,9 +110,9 @@ class test_ids_stuff(ImporterCase): 'message': u"Unknown database identifier '42'", }]) def test_create_with_xid(self): - ids, messages = self.import_(['id', 'value'], [['somexmlid', '42']]) - self.assertEqual(len(ids), 1) - self.assertFalse(messages) + result = self.import_(['id', 'value'], [['somexmlid', '42']]) + self.assertEqual(len(result['ids']), 1) + self.assertFalse(result['messages']) self.assertEqual( 'somexmlid', self.xid(self.browse()[0])) @@ -123,9 +123,9 @@ class test_ids_stuff(ImporterCase): 36, self.model.browse(self.cr, openerp.SUPERUSER_ID, id).value) - ids, messages = self.import_(['.id', 'value'], [[str(id), '42']]) - self.assertEqual(len(ids), 1) - self.assertFalse(messages) + result = self.import_(['.id', 'value'], [[str(id), '42']]) + self.assertEqual(len(result['ids']), 1) + self.assertFalse(result['messages']) self.assertEqual( [42], # updated value to imported values(self.read())) @@ -143,12 +143,12 @@ class test_boolean_field(ImporterCase): def test_empty(self): self.assertEqual( self.import_(['value'], []), - ([], [])) + {'ids': [], 'messages': []}) def test_exported(self): - ids, messages = self.import_(['value'], [['False'], ['True'], ]) - self.assertEqual(len(ids), 2) - self.assertFalse(messages) + result = self.import_(['value'], [['False'], ['True'], ]) + self.assertEqual(len(result['ids']), 2) + self.assertFalse(result['messages']) records = self.read() self.assertEqual([ False, @@ -170,18 +170,18 @@ class test_boolean_field(ImporterCase): [u'klaidingas'], # false, lt, ] - ids, messages = self.import_(['value'], falses) - self.assertFalse(messages) - self.assertEqual(len(ids), len(falses)) + result = self.import_(['value'], falses) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), len(falses)) self.assertEqual([False] * len(falses), values(self.read())) def test_trues(self): trues = [['None'], ['nil'], ['()'], ['f'], ['#f'], # Problem: OpenOffice (and probably excel) output localized booleans ['VRAI'], ['ok'], ['true'], ['yes'], ['1'], ] - ids, messages = self.import_(['value'], trues) - self.assertEqual(len(ids), 10) - self.assertEqual(messages, [ + result = self.import_(['value'], trues) + self.assertEqual(len(result['ids']), 10) + self.assertEqual(result['messages'], [ message(u"Unknown value '%s' for boolean field 'unknown', assuming 'yes'" % v[0], type='warning', from_=i, to_=i, record=i) for i, v in enumerate(trues) @@ -197,69 +197,69 @@ class test_integer_field(ImporterCase): def test_none(self): self.assertEqual( self.import_(['value'], []), - ([], [])) + {'ids': [], 'messages': []}) def test_empty(self): - ids, messages = self.import_(['value'], [['']]) - self.assertEqual(len(ids), 1) - self.assertFalse(messages) + result = self.import_(['value'], [['']]) + self.assertEqual(len(result['ids']), 1) + self.assertFalse(result['messages']) self.assertEqual( [False], values(self.read())) def test_zero(self): - ids, messages = self.import_(['value'], [['0']]) - self.assertEqual(len(ids), 1) - self.assertFalse(messages) + result = self.import_(['value'], [['0']]) + self.assertEqual(len(result['ids']), 1) + self.assertFalse(result['messages']) - ids, messages = self.import_(['value'], [['-0']]) - self.assertEqual(len(ids), 1) - self.assertFalse(messages) + result = self.import_(['value'], [['-0']]) + self.assertEqual(len(result['ids']), 1) + self.assertFalse(result['messages']) self.assertEqual([False, False], values(self.read())) def test_positives(self): - ids, messages = self.import_(['value'], [ + result = self.import_(['value'], [ ['1'], ['42'], [str(2**31-1)], ['12345678'] ]) - self.assertEqual(len(ids), 4) - self.assertFalse(messages) + self.assertEqual(len(result['ids']), 4) + self.assertFalse(result['messages']) self.assertEqual([ 1, 42, 2**31-1, 12345678 ], values(self.read())) def test_negatives(self): - ids, messages = self.import_(['value'], [ + result = self.import_(['value'], [ ['-1'], ['-42'], [str(-(2**31 - 1))], [str(-(2**31))], ['-12345678'] ]) - self.assertEqual(len(ids), 5) - self.assertFalse(messages) + self.assertEqual(len(result['ids']), 5) + self.assertFalse(result['messages']) self.assertEqual([ -1, -42, -(2**31 - 1), -(2**31), -12345678 ], values(self.read())) @mute_logger('openerp.sql_db') def test_out_of_range(self): - ids, messages = self.import_(['value'], [[str(2**31)]]) - self.assertIs(ids, False) - self.assertEqual(messages, [{ + result = self.import_(['value'], [[str(2**31)]]) + self.assertIs(result['ids'], False) + self.assertEqual(result['messages'], [{ 'type': 'error', 'rows': {'from': 0, 'to': 0}, 'record': 0, 'message': "integer out of range\n" }]) - ids, messages = self.import_(['value'], [[str(-2**32)]]) - self.assertIs(ids, False) - self.assertEqual(messages, [{ + result = self.import_(['value'], [[str(-2**32)]]) + self.assertIs(result['ids'], False) + self.assertEqual(result['messages'], [{ 'type': 'error', 'rows': {'from': 0, 'to': 0}, 'record': 0, @@ -267,9 +267,9 @@ class test_integer_field(ImporterCase): }]) def test_nonsense(self): - ids, messages = self.import_(['value'], [['zorglub']]) - self.assertIs(ids, False) - self.assertEqual(messages, [{ + result = self.import_(['value'], [['zorglub']]) + self.assertIs(result['ids'], False) + self.assertEqual(result['messages'], [{ 'type': 'error', 'rows': {'from': 0, 'to': 0}, 'record': 0, @@ -282,29 +282,29 @@ class test_float_field(ImporterCase): def test_none(self): self.assertEqual( self.import_(['value'], []), - ([], [])) + {'ids': [], 'messages': []}) def test_empty(self): - ids, messages = self.import_(['value'], [['']]) - self.assertEqual(len(ids), 1) - self.assertFalse(messages) + result = self.import_(['value'], [['']]) + self.assertEqual(len(result['ids']), 1) + self.assertFalse(result['messages']) self.assertEqual( [False], values(self.read())) def test_zero(self): - ids, messages = self.import_(['value'], [['0']]) - self.assertEqual(len(ids), 1) - self.assertFalse(messages) + result = self.import_(['value'], [['0']]) + self.assertEqual(len(result['ids']), 1) + self.assertFalse(result['messages']) - ids, messages = self.import_(['value'], [['-0']]) - self.assertEqual(len(ids), 1) - self.assertFalse(messages) + result = self.import_(['value'], [['-0']]) + self.assertEqual(len(result['ids']), 1) + self.assertFalse(result['messages']) self.assertEqual([False, False], values(self.read())) def test_positives(self): - ids, messages = self.import_(['value'], [ + result = self.import_(['value'], [ ['1'], ['42'], [str(2**31-1)], @@ -312,15 +312,15 @@ class test_float_field(ImporterCase): [str(2**33)], ['0.000001'], ]) - self.assertEqual(len(ids), 6) - self.assertFalse(messages) + self.assertEqual(len(result['ids']), 6) + self.assertFalse(result['messages']) self.assertEqual([ 1, 42, 2**31-1, 12345678, 2.0**33, .000001 ], values(self.read())) def test_negatives(self): - ids, messages = self.import_(['value'], [ + result = self.import_(['value'], [ ['-1'], ['-42'], [str(-2**31 + 1)], @@ -329,16 +329,16 @@ class test_float_field(ImporterCase): [str(-2**33)], ['-0.000001'], ]) - self.assertEqual(len(ids), 7) - self.assertFalse(messages) + self.assertEqual(len(result['ids']), 7) + self.assertFalse(result['messages']) self.assertEqual([ -1, -42, -(2**31 - 1), -(2**31), -12345678, -2.0**33, -.000001 ], values(self.read())) def test_nonsense(self): - ids, messages = self.import_(['value'], [['foobar']]) - self.assertIs(ids, False) - self.assertEqual(messages, [{ + result = self.import_(['value'], [['foobar']]) + self.assertIs(result['ids'], False) + self.assertEqual(result['messages'], [{ 'type': 'error', 'rows': {'from': 0, 'to': 0}, 'record': 0, @@ -350,13 +350,13 @@ class test_string_field(ImporterCase): model_name = 'export.string.bounded' def test_empty(self): - ids, messages = self.import_(['value'], [['']]) - self.assertEqual(len(ids), 1) - self.assertFalse(messages) + result = self.import_(['value'], [['']]) + self.assertEqual(len(result['ids']), 1) + self.assertFalse(result['messages']) self.assertEqual([False], values(self.read())) def test_imported(self): - ids, messages = self.import_(['value'], [ + result = self.import_(['value'], [ [u'foobar'], [u'foobarbaz'], [u'Með suð í eyrum við spilum endalaust'], @@ -364,8 +364,8 @@ class test_string_field(ImporterCase): u"someone he can't pound a nail with a banana doesn't much " u"surprise him."] ]) - self.assertEqual(len(ids), 4) - self.assertFalse(messages) + self.assertEqual(len(result['ids']), 4) + self.assertFalse(result['messages']) self.assertEqual([ u"foobar", u"foobarbaz", @@ -377,14 +377,14 @@ class test_unbound_string_field(ImporterCase): model_name = 'export.string' def test_imported(self): - ids, messages = self.import_(['value'], [ + result = self.import_(['value'], [ [u'í dag viðrar vel til loftárása'], # ackbar.jpg [u"If they ask you about fun, you tell them – fun is a filthy" u" parasite"] ]) - self.assertEqual(len(ids), 2) - self.assertFalse(messages) + self.assertEqual(len(result['ids']), 2) + self.assertFalse(result['messages']) self.assertEqual([ u"í dag viðrar vel til loftárása", u"If they ask you about fun, you tell them – fun is a filthy parasite" @@ -394,9 +394,9 @@ class test_text(ImporterCase): model_name = 'export.text' def test_empty(self): - ids, messages = self.import_(['value'], [['']]) - self.assertEqual(len(ids), 1) - self.assertFalse(messages) + result = self.import_(['value'], [['']]) + self.assertEqual(len(result['ids']), 1) + self.assertFalse(result['messages']) self.assertEqual([False], values(self.read())) def test_imported(self): @@ -406,9 +406,9 @@ class test_text(ImporterCase): u"snúninga 12 tommu vínylplötur (sem geta verið allt að 30 mín " u"hvor hlið).\n\nBreiðskífur eru stundum tvöfaldar og eru þær þá" u" gefnar út á tveimur geisladiskum eða tveimur vínylplötum.") - ids, messages = self.import_(['value'], [[s]]) - self.assertEqual(len(ids), 1) - self.assertFalse(messages) + result = self.import_(['value'], [[s]]) + self.assertEqual(len(result['ids']), 1) + self.assertFalse(result['messages']) self.assertEqual([s], values(self.read())) class test_selection(ImporterCase): @@ -420,44 +420,44 @@ class test_selection(ImporterCase): ] def test_imported(self): - ids, messages = self.import_(['value'], [ + result = self.import_(['value'], [ ['Qux'], ['Bar'], ['Foo'], ['2'], ]) - self.assertEqual(len(ids), 4) - self.assertFalse(messages) + self.assertEqual(len(result['ids']), 4) + self.assertFalse(result['messages']) self.assertEqual([3, 2, 1, 2], values(self.read())) def test_imported_translated(self): self.add_translations( 'export.selection,value', 'selection', 'fr_FR', *self.translations_fr) - ids, messages = self.import_(['value'], [ + result = self.import_(['value'], [ ['toto'], ['tete'], ['titi'], ], context={'lang': 'fr_FR'}) - self.assertEqual(len(ids), 3) - self.assertFalse(messages) + self.assertEqual(len(result['ids']), 3) + self.assertFalse(result['messages']) self.assertEqual([3, 1, 2], values(self.read())) - ids, messages = self.import_(['value'], [['Foo']], context={'lang': 'fr_FR'}) - self.assertEqual(len(ids), 1) - self.assertFalse(messages) + result = self.import_(['value'], [['Foo']], context={'lang': 'fr_FR'}) + self.assertEqual(len(result['ids']), 1) + self.assertFalse(result['messages']) def test_invalid(self): - ids, messages = self.import_(['value'], [['Baz']]) - self.assertIs(ids, False) - self.assertEqual(messages, [message( + result = self.import_(['value'], [['Baz']]) + self.assertIs(result['ids'], False) + self.assertEqual(result['messages'], [message( u"Value 'Baz' not found in selection field 'unknown'", moreinfo="Foo Bar Qux".split())]) - ids, messages = self.import_(['value'], [[42]]) - self.assertIs(ids, False) - self.assertEqual(messages, [message( + result = self.import_(['value'], [[42]]) + self.assertIs(result['ids'], False) + self.assertEqual(result['messages'], [message( u"Value '42' not found in selection field 'unknown'", moreinfo="Foo Bar Qux".split())]) @@ -476,12 +476,12 @@ class test_selection_function(ImporterCase): import does not actually know that the selection field uses a function """ # NOTE: conflict between a value and a label => pick first - ids, messages = self.import_(['value'], [ + result = self.import_(['value'], [ ['3'], ["Grault"], ]) - self.assertEqual(len(ids), 2) - self.assertFalse(messages) + self.assertEqual(len(result['ids']), 2) + self.assertFalse(result['messages']) self.assertEqual( ['3', '1'], values(self.read())) @@ -492,17 +492,17 @@ class test_selection_function(ImporterCase): self.add_translations( 'export.selection,value', 'selection', 'fr_FR', *self.translations_fr) - ids, messages = self.import_(['value'], [ + result = self.import_(['value'], [ ['titi'], ['tete'], ], context={'lang': 'fr_FR'}) - self.assertFalse(messages) - self.assertEqual(len(ids), 2) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 2) self.assertEqual(values(self.read()), ['1', '2']) - ids, messages = self.import_(['value'], [['Wheee']], context={'lang': 'fr_FR'}) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + result = self.import_(['value'], [['Wheee']], context={'lang': 'fr_FR'}) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) class test_m2o(ImporterCase): model_name = 'export.many2one' @@ -519,14 +519,14 @@ class test_m2o(ImporterCase): name2 = dict(self.registry('export.integer').name_get( self.cr, openerp.SUPERUSER_ID,[integer_id2]))[integer_id2] - ids , messages = self.import_(['value'], [ + result = self.import_(['value'], [ # import by name_get [name1], [name1], [name2], ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 3) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 3) # correct ids assigned to corresponding records self.assertEqual([ (integer_id1, name1), @@ -541,18 +541,18 @@ class test_m2o(ImporterCase): xid = self.xid(ExportInteger.browse( self.cr, openerp.SUPERUSER_ID, [integer_id])[0]) - ids, messages = self.import_(['value/id'], [[xid]]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + result = self.import_(['value/id'], [[xid]]) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) b = self.browse() self.assertEqual(42, b[0].value.value) def test_by_id(self): integer_id = self.registry('export.integer').create( self.cr, openerp.SUPERUSER_ID, {'value': 42}) - ids, messages = self.import_(['value/.id'], [[integer_id]]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + result = self.import_(['value/.id'], [[integer_id]]) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) b = self.browse() self.assertEqual(42, b[0].value.value) @@ -568,12 +568,12 @@ class test_m2o(ImporterCase): # names should be the same self.assertEqual(name1, name2) - ids, messages = self.import_(['value'], [[name2]]) + result = self.import_(['value'], [[name2]]) self.assertEqual( - messages, + result['messages'], [message(u"Found multiple matches for field 'unknown' (2 matches)", type='warning')]) - self.assertEqual(len(ids), 1) + self.assertEqual(len(result['ids']), 1) self.assertEqual([ (integer_id1, name1) ], values(self.read())) @@ -588,55 +588,55 @@ class test_m2o(ImporterCase): self.cr, openerp.SUPERUSER_ID, {'value': 36}) # Because name_search all the things. Fallback schmallback - ids, messages = self.import_(['value'], [ + result = self.import_(['value'], [ # import by id, without specifying it [integer_id1], [integer_id2], [integer_id1], ]) - self.assertEqual(messages, [ + self.assertEqual(result['messages'], [ message(u"No matching record found for name '%s' in field 'unknown'" % id, from_=index, to_=index, record=index) for index, id in enumerate([integer_id1, integer_id2, integer_id1])]) - self.assertIs(ids, False) + self.assertIs(result['ids'], False) def test_sub_field(self): """ Does not implicitly create the record, does not warn that you can't import m2o subfields (at all)... """ - ids, messages = self.import_(['value/value'], [['42']]) - self.assertEqual(messages, [ + result = self.import_(['value/value'], [['42']]) + self.assertEqual(result['messages'], [ message(u"Can not create Many-To-One records indirectly, import " u"the field separately")]) - self.assertIs(ids, False) + self.assertIs(result['ids'], False) def test_fail_noids(self): - ids, messages = self.import_(['value'], [['nameisnoexist:3']]) - self.assertEqual(messages, [message( + result = self.import_(['value'], [['nameisnoexist:3']]) + self.assertEqual(result['messages'], [message( u"No matching record found for name 'nameisnoexist:3' " u"in field 'unknown'")]) - self.assertIs(ids, False) + self.assertIs(result['ids'], False) - ids, messages = self.import_(['value/id'], [['noxidhere']]) - self.assertEqual(messages, [message( + result = self.import_(['value/id'], [['noxidhere']]) + self.assertEqual(result['messages'], [message( u"No matching record found for external id 'noxidhere' " u"in field 'unknown'")]) - self.assertIs(ids, False) + self.assertIs(result['ids'], False) - ids, messages = self.import_(['value/.id'], [['66']]) - self.assertEqual(messages, [message( + result = self.import_(['value/.id'], [['66']]) + self.assertEqual(result['messages'], [message( u"No matching record found for database id '66' " u"in field 'unknown'")]) - self.assertIs(ids, False) + self.assertIs(result['ids'], False) def test_fail_multiple(self): - ids, messages = self.import_( + result = self.import_( ['value', 'value/id'], [['somename', 'somexid']]) - self.assertEqual(messages, [message( + self.assertEqual(result['messages'], [message( u"Ambiguous specification for field 'unknown', only provide one of " u"name, external id or database id")]) - self.assertIs(ids, False) + self.assertIs(result['ids'], False) class test_m2m(ImporterCase): model_name = 'export.many2many' @@ -656,14 +656,14 @@ class test_m2m(ImporterCase): id5 = self.registry('export.many2many.other').create( self.cr, openerp.SUPERUSER_ID, {'value': 99, 'str': 'record4'}) - ids, messages = self.import_(['value/.id'], [ + result = self.import_(['value/.id'], [ ['%d,%d' % (id1, id2)], ['%d,%d,%d' % (id1, id3, id4)], ['%d,%d,%d' % (id1, id2, id3)], ['%d' % id5] ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 4) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 4) ids = lambda records: [record.id for record in records] @@ -675,11 +675,11 @@ class test_m2m(ImporterCase): self.assertEqual(values(b[2].value), [3, 44, 84]) def test_noids(self): - ids, messages = self.import_(['value/.id'], [['42']]) - self.assertEqual(messages, [message( + result = self.import_(['value/.id'], [['42']]) + self.assertEqual(result['messages'], [message( u"No matching record found for database id '42' in field " u"'unknown'")]) - self.assertIs(ids, False) + self.assertIs(result['ids'], False) def test_xids(self): M2O_o = self.registry('export.many2many.other') @@ -689,23 +689,23 @@ class test_m2m(ImporterCase): id4 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 9, 'str': 'record3'}) records = M2O_o.browse(self.cr, openerp.SUPERUSER_ID, [id1, id2, id3, id4]) - ids, messages = self.import_(['value/id'], [ + result = self.import_(['value/id'], [ ['%s,%s' % (self.xid(records[0]), self.xid(records[1]))], ['%s' % self.xid(records[3])], ['%s,%s' % (self.xid(records[2]), self.xid(records[1]))], ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 3) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 3) b = self.browse() self.assertEqual(values(b[0].value), [3, 44]) self.assertEqual(values(b[2].value), [44, 84]) def test_noxids(self): - ids, messages = self.import_(['value/id'], [['noxidforthat']]) - self.assertEqual(messages, [message( + result = self.import_(['value/id'], [['noxidforthat']]) + self.assertEqual(result['messages'], [message( u"No matching record found for external id 'noxidforthat' " u"in field 'unknown'")]) - self.assertIs(ids, False) + self.assertIs(result['ids'], False) def test_names(self): M2O_o = self.registry('export.many2many.other') @@ -717,24 +717,24 @@ class test_m2m(ImporterCase): name = lambda record: dict(record.name_get())[record.id] - ids, messages = self.import_(['value'], [ + result = self.import_(['value'], [ ['%s,%s' % (name(records[1]), name(records[2]))], ['%s,%s,%s' % (name(records[0]), name(records[1]), name(records[2]))], ['%s,%s' % (name(records[0]), name(records[3]))], ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 3) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 3) b = self.browse() self.assertEqual(values(b[1].value), [3, 44, 84]) self.assertEqual(values(b[2].value), [3, 9]) def test_nonames(self): - ids, messages = self.import_(['value'], [['wherethem2mhavenonames']]) - self.assertEqual(messages, [message( + result = self.import_(['value'], [['wherethem2mhavenonames']]) + self.assertEqual(result['messages'], [message( u"No matching record found for name 'wherethem2mhavenonames' in " u"field 'unknown'")]) - self.assertIs(ids, False) + self.assertIs(result['ids'], False) def test_import_to_existing(self): M2O_o = self.registry('export.many2many.other') @@ -744,12 +744,12 @@ class test_m2m(ImporterCase): id4 = M2O_o.create(self.cr, openerp.SUPERUSER_ID, {'value': 9, 'str': 'record3'}) xid = 'myxid' - ids, messages = self.import_(['id', 'value/.id'], [[xid, '%d,%d' % (id1, id2)]]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) - ids, messages = self.import_(['id', 'value/.id'], [[xid, '%d,%d' % (id3, id4)]]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + result = self.import_(['id', 'value/.id'], [[xid, '%d,%d' % (id1, id2)]]) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) + result = self.import_(['id', 'value/.id'], [[xid, '%d,%d' % (id3, id4)]]) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) b = self.browse() self.assertEqual(len(b), 1) @@ -762,31 +762,31 @@ class test_o2m(ImporterCase): def test_name_get(self): s = u'Java is a DSL for taking large XML files and converting them ' \ u'to stack traces' - ids, messages = self.import_( + result = self.import_( ['const', 'value'], [['5', s]]) - self.assertEqual(messages, [message( + self.assertEqual(result['messages'], [message( u"No matching record found for name '%s' in field 'unknown'" % s)]) - self.assertIs(ids, False) + self.assertIs(result['ids'], False) def test_single(self): - ids, messages = self.import_(['const', 'value/value'], [ + result = self.import_(['const', 'value/value'], [ ['5', '63'] ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) (b,) = self.browse() self.assertEqual(b.const, 5) self.assertEqual(values(b.value), [63]) def test_multicore(self): - ids, messages = self.import_(['const', 'value/value'], [ + result = self.import_(['const', 'value/value'], [ ['5', '63'], ['6', '64'], ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 2) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 2) b1, b2 = self.browse() self.assertEqual(b1.const, 5) @@ -795,27 +795,27 @@ class test_o2m(ImporterCase): self.assertEqual(values(b2.value), [64]) def test_multisub(self): - ids, messages = self.import_(['const', 'value/value'], [ + result = self.import_(['const', 'value/value'], [ ['5', '63'], ['', '64'], ['', '65'], ['', '66'], ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) (b,) = self.browse() self.assertEqual(values(b.value), [63, 64, 65, 66]) def test_multi_subfields(self): - ids, messages = self.import_(['value/str', 'const', 'value/value'], [ + result = self.import_(['value/str', 'const', 'value/value'], [ ['this', '5', '63'], ['is', '', '64'], ['the', '', '65'], ['rhythm', '', '66'], ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) (b,) = self.browse() self.assertEqual(values(b.value), [63, 64, 65, 66]) @@ -833,11 +833,11 @@ class test_o2m(ImporterCase): 'str': 'Me', 'value': 262 }) - ids, messages = self.import_(['const', 'value/.id'], [ + result = self.import_(['const', 'value/.id'], [ ['42', '%d,%d' % (id1, id2)] ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) [b] = self.browse() self.assertEqual(b.const, 42) @@ -856,12 +856,12 @@ class test_o2m(ImporterCase): 'str': 'Me', 'value': 262 }) - ids, messages = self.import_(['const', 'value/.id'], [ + result = self.import_(['const', 'value/.id'], [ ['42', str(id1)], ['', str(id2)], ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) [b] = self.browse() self.assertEqual(b.const, 42) @@ -878,12 +878,12 @@ class test_o2m(ImporterCase): 'str': 'Me', 'value': 262 }) - ids, messages = self.import_(['const', 'value/.id', 'value/value'], [ + result = self.import_(['const', 'value/.id', 'value/value'], [ ['42', str(id1), '1'], ['', str(id2), '2'], ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) [b] = self.browse() self.assertEqual(b.const, 42) @@ -894,21 +894,21 @@ class test_o2m_multiple(ImporterCase): model_name = 'export.one2many.multiple' def test_multi_mixed(self): - ids, messages = self.import_(['const', 'child1/value', 'child2/value'], [ + result = self.import_(['const', 'child1/value', 'child2/value'], [ ['5', '11', '21'], ['', '12', '22'], ['', '13', '23'], ['', '14', ''], ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) # Oh yeah, that's the stuff [b] = self.browse() self.assertEqual(values(b.child1), [11, 12, 13, 14]) self.assertEqual(values(b.child2), [21, 22, 23]) def test_multi(self): - ids, messages = self.import_(['const', 'child1/value', 'child2/value'], [ + result = self.import_(['const', 'child1/value', 'child2/value'], [ ['5', '11', '21'], ['', '12', ''], ['', '13', ''], @@ -916,15 +916,15 @@ class test_o2m_multiple(ImporterCase): ['', '', '22'], ['', '', '23'], ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) [b] = self.browse() self.assertEqual(values(b.child1), [11, 12, 13, 14]) self.assertEqual(values(b.child2), [21, 22, 23]) def test_multi_fullsplit(self): - ids, messages = self.import_(['const', 'child1/value', 'child2/value'], [ + result = self.import_(['const', 'child1/value', 'child2/value'], [ ['5', '11', ''], ['', '12', ''], ['', '13', ''], @@ -933,8 +933,8 @@ class test_o2m_multiple(ImporterCase): ['', '', '22'], ['', '', '23'], ]) - self.assertFalse(messages) - self.assertEqual(len(ids), 1) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) [b] = self.browse() self.assertEqual(b.const, 5) From e6c8f1739a70ba8c0252ed8ace839c30f7e794dc Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 25 Sep 2012 09:42:56 +0200 Subject: [PATCH 12/43] [ADD] 'more info' action to m2o, o2m and m2m linking failures bzr revid: xmo@openerp.com-20120925074256-18puerjbfo3om265 --- openerp/addons/base/ir/ir_fields.py | 31 +++++++------- .../addons/test_impex/tests/test_load.py | 42 +++++++++---------- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 2256542533b..cec74e8976e 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -201,6 +201,21 @@ class ir_fields_converter(orm.Model): id, _name = ids[0] else: raise Exception(u"Unknown sub-field '%s'" % subfield) + + if id is None: + raise ValueError( + _(u"No matching record found for %(field_type)s '%(value)s' in field '%%(field)s'") + % {'field_type': field_type, 'value': value}, { + 'moreinfo': { + 'type': 'ir.actions.act_window', + 'target': 'new', + 'res_model': column._obj, + 'view_mode': 'tree,form', + 'view_type': 'form', + 'views': [(False, 'tree', (False, 'form'))], + 'help': _(u"See all possible values") + } + }) return id, field_type def _referencing_subfield(self, record): @@ -235,12 +250,8 @@ class ir_fields_converter(orm.Model): reference = record[subfield] id, subfield_type = self.db_id_for( cr, uid, model, column, subfield, reference, context=context) - - if id is None: - raise ValueError( - _(u"No matching record found for %(field_type)s '%(value)s' in field '%%(field)s'") - % {'field_type': subfield_type, 'value': reference}) return id + def _str_to_many2many(self, cr, uid, model, column, value, context=None): [record] = value @@ -250,13 +261,9 @@ class ir_fields_converter(orm.Model): for reference in record[subfield].split(','): id, subfield_type = self.db_id_for( cr, uid, model, column, subfield, reference, context=context) - if id is None: - raise ValueError( - _(u"No matching record found for %(field_type)s '%(value)s' in field '%%(field)s'") - % {'field_type': subfield_type, 'value': reference}) ids.append(id) - return [(6, 0, ids)] + def _str_to_one2many(self, cr, uid, model, column, records, context=None): commands = [] @@ -278,10 +285,6 @@ class ir_fields_converter(orm.Model): reference = record[subfield] id, subfield_type = self.db_id_for( cr, uid, model, column, subfield, reference, context=context) - if id is None: - raise ValueError( - _(u"No matching record found for %(field_type)s '%(value)s' in field '%%(field)s'") - % {'field_type': subfield_type, 'value': reference}) writable = exclude_ref_fields(record) if id: diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 060b32c48f5..f625ecb7043 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -9,18 +9,16 @@ def message(msg, type='error', from_=0, to_=0, record=0, field='value', **kwargs return dict(kwargs, type=type, rows={'from': from_, 'to': to_}, record=record, field=field, message=msg) - -def error(row, message, record=None, **kwargs): - """ Failed import of the record ``record`` at line ``row``, with the error - message ``message`` - - :param str message: - :param dict record: - """ - return ( - -1, dict(record or {}, **kwargs), - "Line %d : %s" % (row, message), - '') +def moreaction(model): + return { + 'type': 'ir.actions.act_window', + 'target': 'new', + 'res_model': model, + 'view_mode': 'tree,form', + 'view_type': 'form', + 'views': [(False, 'tree', (False, 'form'))], + 'help': u"See all possible values" + } def values(seq, field='value'): return [item[field] for item in seq] @@ -596,7 +594,8 @@ class test_m2o(ImporterCase): ]) self.assertEqual(result['messages'], [ message(u"No matching record found for name '%s' in field 'unknown'" % id, - from_=index, to_=index, record=index) + from_=index, to_=index, record=index, + moreinfo=moreaction('export.integer')) for index, id in enumerate([integer_id1, integer_id2, integer_id1])]) self.assertIs(result['ids'], False) @@ -614,19 +613,19 @@ class test_m2o(ImporterCase): result = self.import_(['value'], [['nameisnoexist:3']]) self.assertEqual(result['messages'], [message( u"No matching record found for name 'nameisnoexist:3' " - u"in field 'unknown'")]) + u"in field 'unknown'", moreinfo=moreaction('export.integer'))]) self.assertIs(result['ids'], False) result = self.import_(['value/id'], [['noxidhere']]) self.assertEqual(result['messages'], [message( u"No matching record found for external id 'noxidhere' " - u"in field 'unknown'")]) + u"in field 'unknown'", moreinfo=moreaction('export.integer'))]) self.assertIs(result['ids'], False) result = self.import_(['value/.id'], [['66']]) self.assertEqual(result['messages'], [message( u"No matching record found for database id '66' " - u"in field 'unknown'")]) + u"in field 'unknown'", moreinfo=moreaction('export.integer'))]) self.assertIs(result['ids'], False) def test_fail_multiple(self): @@ -678,7 +677,7 @@ class test_m2m(ImporterCase): result = self.import_(['value/.id'], [['42']]) self.assertEqual(result['messages'], [message( u"No matching record found for database id '42' in field " - u"'unknown'")]) + u"'unknown'", moreinfo=moreaction('export.many2many.other'))]) self.assertIs(result['ids'], False) def test_xids(self): @@ -703,8 +702,8 @@ class test_m2m(ImporterCase): def test_noxids(self): result = self.import_(['value/id'], [['noxidforthat']]) self.assertEqual(result['messages'], [message( - u"No matching record found for external id 'noxidforthat' " - u"in field 'unknown'")]) + u"No matching record found for external id 'noxidforthat' in field" + u" 'unknown'", moreinfo=moreaction('export.many2many.other'))]) self.assertIs(result['ids'], False) def test_names(self): @@ -733,7 +732,7 @@ class test_m2m(ImporterCase): result = self.import_(['value'], [['wherethem2mhavenonames']]) self.assertEqual(result['messages'], [message( u"No matching record found for name 'wherethem2mhavenonames' in " - u"field 'unknown'")]) + u"field 'unknown'", moreinfo=moreaction('export.many2many.other'))]) self.assertIs(result['ids'], False) def test_import_to_existing(self): @@ -766,7 +765,8 @@ class test_o2m(ImporterCase): ['const', 'value'], [['5', s]]) self.assertEqual(result['messages'], [message( - u"No matching record found for name '%s' in field 'unknown'" % s)]) + u"No matching record found for name '%s' in field 'unknown'" % s, + moreinfo=moreaction('export.one2many.child'))]) self.assertIs(result['ids'], False) def test_single(self): From 359b4a44aa2d10cac6a0ce9bd623ac6b4e1a17d7 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 25 Sep 2012 12:02:32 +0200 Subject: [PATCH 13/43] [ADD] ability to convert postgres error messages to human-readable ones also convert 'violates not-null constraint' to something about fields being required bzr revid: xmo@openerp.com-20120925100232-bfmxcxda65cki5kv --- openerp/osv/orm.py | 31 ++++++++++++++++++- openerp/tests/addons/test_impex/models.py | 1 + .../addons/test_impex/tests/test_load.py | 15 +++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index b824be91b96..25974760bdd 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -42,6 +42,7 @@ """ import calendar +import collections import copy import datetime import itertools @@ -1493,6 +1494,7 @@ class BaseModel(object): fields = map(fix_import_export_id_paths, fields) ModelData = self.pool['ir.model.data'] + fg = self.fields_get(cr, uid, context=context) mode = 'init' current_module = '' @@ -1509,11 +1511,16 @@ class BaseModel(object): current_module, record, mode=mode, xml_id=xid, noupdate=noupdate, res_id=id, context=context)) cr.execute('RELEASE SAVEPOINT model_load_save') + except psycopg2.Warning, e: + cr.execute('ROLLBACK TO SAVEPOINT model_load_save') + messages.append(dict(info, type='warning', message=str(e))) except psycopg2.Error, e: # Failed to write, log to messages, rollback savepoint (to # avoid broken transaction) and keep going cr.execute('ROLLBACK TO SAVEPOINT model_load_save') - messages.append(dict(info, type="error", message=str(e))) + messages.append(dict( + info, type="error", + **PGERROR_TO_OE[e.pgcode](self, fg, info, e))) if any(message['type'] == 'error' for message in messages): cr.execute('ROLLBACK TO SAVEPOINT model_load') ids = False @@ -5390,4 +5397,26 @@ class ImportWarning(Warning): """ Used to send warnings upwards the stack during the import process """ pass + + +def convert_pgerror_23502(model, fields, info, e): + m = re.match(r'^null value in column "(?P\w+)" violates ' + r'not-null constraint\n$', + str(e)) + if not m or m.group('field') not in fields: + return {'message': unicode(e)} + field = fields[m.group('field')] + return { + 'message': _(u"Missing required value for the field '%(field)s'") % { + 'field': field['string'] + }, + 'field': m.group('field'), + } + +PGERROR_TO_OE = collections.defaultdict( + # shape of mapped converters + lambda: (lambda model, fvg, info, pgerror: {'message': unicode(pgerror)}), { + # not_null_violation + '23502': convert_pgerror_23502, +}) # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/openerp/tests/addons/test_impex/models.py b/openerp/tests/addons/test_impex/models.py index 37894ccb78c..c56ddc649e2 100644 --- a/openerp/tests/addons/test_impex/models.py +++ b/openerp/tests/addons/test_impex/models.py @@ -17,6 +17,7 @@ models = [ ('float', fields.float()), ('decimal', fields.float(digits=(16, 3))), ('string.bounded', fields.char('unknown', size=16)), + ('string.required', fields.char('unknown', size=None, required=True)), ('string', fields.char('unknown', size=None)), ('date', fields.date()), ('datetime', fields.datetime()), diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index f625ecb7043..eb777a57988 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -388,6 +388,21 @@ class test_unbound_string_field(ImporterCase): u"If they ask you about fun, you tell them – fun is a filthy parasite" ], values(self.read())) +class test_required_string_field(ImporterCase): + model_name = 'export.string.required' + + def test_empty(self): + result = self.import_(['value'], [[]]) + self.assertEqual(result['messages'], [message( + u"Missing required value for the field 'unknown'")]) + self.assertIs(result['ids'], False) + + def test_not_provided(self): + result = self.import_(['const'], [['12']]) + self.assertEqual(result['messages'], [message( + u"Missing required value for the field 'unknown'")]) + self.assertIs(result['ids'], False) + class test_text(ImporterCase): model_name = 'export.text' From d5c69fa87e9d17c92c00347af73fce53fb47dcd4 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 25 Sep 2012 15:59:55 +0200 Subject: [PATCH 14/43] [FIX] use lists instead of iterators in BaseModel._extract_records * although it does use an explicit external index, it turns out the code is less complex * the rewrapping of (many) iterators on top of one another ended up blowing Python's stack during ``next`` calls, which Python does *not* like * added a 900-ish import test file to check for these things bzr revid: xmo@openerp.com-20120925135955-oielhopegnefyctm --- openerp/addons/base/ir/ir_fields.py | 5 +- openerp/osv/orm.py | 53 +++++-------------- .../addons/test_impex/tests/test_load.py | 13 +++++ 3 files changed, 30 insertions(+), 41 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index cec74e8976e..03d95fc67a6 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -69,7 +69,7 @@ class ir_fields_converter(orm.Model): """ # FIXME: return None converter = getattr( - self, '_%s_to_%s' % (fromtype.__name__, column._type)) + self, '_%s_to_%s' % (fromtype.__name__, column._type), None) if not converter: return None return functools.partial( @@ -113,6 +113,9 @@ class ir_fields_converter(orm.Model): def _str_to_text(self, cr, uid, model, column, value, context=None): return value or False + def _str_to_binary(self, cr, uid, model, column, value, context=None): + return value or False + def _get_translations(self, cr, uid, types, src, context): types = tuple(types) # Cache translations so they don't have to be reloaded from scratch on diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index 25974760bdd..eb7745b2f6e 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1527,7 +1527,7 @@ class BaseModel(object): return {'ids': ids, 'messages': messages} def _extract_records(self, cr, uid, fields_, data, context=None, log=lambda a: None): - """ Generates record dicts from the data iterable. + """ Generates record dicts from the data sequence. The result is a generator of dicts mapping field names to raw (unconverted, unvalidated) values. @@ -1562,12 +1562,11 @@ class BaseModel(object): def only_o2m_values(row, f=get_nono2m_values, g=get_o2m_values): return any(g(row)) and not any(f(row)) - rows = CountingStream(data) + index = 0 while True: - row = next(rows, None) - if row is None: return - record_row_index = rows.index + if index >= len(data): return + row = data[index] # copy non-relational fields to record dict record = dict((field[0], value) for field, value in itertools.izip(fields_, row) @@ -1575,10 +1574,10 @@ class BaseModel(object): # Get all following rows which have relational values attached to # the current record (no non-relational values) - # WARNING: replaces existing ``rows`` - record_span, _rows = span(only_o2m_values, rows) + record_span = itertools.takewhile( + only_o2m_values, itertools.islice(data, index + 1, None)) # stitch record row back on for relational fields - record_span = itertools.chain([row], record_span) + record_span = list(itertools.chain([row], record_span)) for relfield in set( field[0] for field in fields_ if is_relational(field[0])): @@ -1586,8 +1585,6 @@ class BaseModel(object): # FIXME: how to not use _obj without relying on fields_get? Model = self.pool[column._obj] - # copy stream to reuse for next relational field - fieldrows, record_span = itertools.tee(record_span) # get only cells for this sub-field, should be strictly # non-empty, field path [None] is for name_get column indices, subfields = zip(*((index, field[1:] or [None]) @@ -1596,26 +1593,17 @@ class BaseModel(object): # return all rows which have at least one value for the # subfields of relfield - relfield_data = filter(any, map(itemgetter_tuple(indices), fieldrows)) + relfield_data = filter(any, map(itemgetter_tuple(indices), record_span)) record[relfield] = [subrecord for subrecord, _subinfo in Model._extract_records( cr, uid, subfields, relfield_data, context=context, log=log)] - # Ensure full consumption of the span (and therefore advancement of - # ``rows``) even if there are no relational fields. Needs two as - # the code above stiched the row back on (so first call may only - # get the stiched row without advancing the underlying operator row - # itself) - next(record_span, None) - next(record_span, None) - # old rows consumption (by iterating the span) should be done here, - # at this point the old ``rows`` is 1 past `span` (either on the - # next record row or past ``StopIteration``, so wrap new ``rows`` - # (``_rows``) in a counting stream indexed 1-before the old - # ``rows`` - rows = CountingStream(_rows, rows.index - 1) - yield record, {'rows': {'from': record_row_index,'to': rows.index}} + yield record, {'rows': { + 'from': index, + 'to': index + len(record_span) - 1 + }} + index += len(record_span) def _convert_records(self, cr, uid, records, context=None, log=lambda a: None): """ Converts records from the source iterable (recursive dicts of @@ -5369,21 +5357,6 @@ class AbstractModel(BaseModel): _auto = False # don't create any database backend for AbstractModels _register = False # not visible in ORM registry, meant to be python-inherited only -def span(predicate, iterable): - """ Splits the iterable between the longest prefix of ``iterable`` whose - elements satisfy ``predicate`` and the rest. - - If called with a list, equivalent to:: - - takewhile(predicate, lst), dropwhile(predicate, lst) - - :param callable predicate: - :param iterable: - :rtype: (iterable, iterable) - """ - it1, it2 = itertools.tee(iterable) - return (itertools.takewhile(predicate, it1), - itertools.dropwhile(predicate, it2)) def itemgetter_tuple(items): """ Fixes itemgetter inconsistency (useful in some cases) of not returning a tuple if len(items) == 1: always returns an n-tuple where n = len(items) diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index eb777a57988..72442963af5 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +import json +import pkgutil + import openerp.modules.registry import openerp @@ -956,5 +959,15 @@ class test_o2m_multiple(ImporterCase): self.assertEqual(values(b.child1), [11, 12, 13, 14]) self.assertEqual(values(b.child2), [21, 22, 23]) +class test_realworld(common.TransactionCase): + def test_bigfile(self): + data = json.loads(pkgutil.get_data(self.__module__, 'contacts_big.json')) + result = self.registry('res.partner').load( + self.cr, openerp.SUPERUSER_ID, + ['name', 'mobile', 'email', 'image'], + data) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), len(data)) + # function, related, reference: written to db as-is... # => function uses @type for value coercion/conversion From e61dc509341ed2cd1222869048a81b2db209e21d Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 25 Sep 2012 17:59:15 +0200 Subject: [PATCH 15/43] [IMP] convert empty import fields to False values without going through converters simplifies the converter methods by avoiding redundant emptiness checks bzr revid: xmo@openerp.com-20120925155915-82p2s6stpww37p5n --- openerp/addons/base/ir/ir_fields.py | 8 +++----- openerp/osv/orm.py | 4 +++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 03d95fc67a6..621dd93d469 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -100,21 +100,19 @@ class ir_fields_converter(orm.Model): return True def _str_to_integer(self, cr, uid, model, column, value, context=None): - if not value: return False return int(value) def _str_to_float(self, cr, uid, model, column, value, context=None): - if not value: return False return float(value) def _str_to_char(self, cr, uid, model, column, value, context=None): - return value or False + return value def _str_to_text(self, cr, uid, model, column, value, context=None): - return value or False + return value def _str_to_binary(self, cr, uid, model, column, value, context=None): - return value or False + return value def _get_translations(self, cr, uid, types, src, context): types = tuple(types) diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index eb7745b2f6e..81063deb180 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -68,7 +68,6 @@ from openerp.tools.safe_eval import safe_eval as eval from openerp.tools.translate import _ from openerp import SUPERUSER_ID from query import Query -from openerp import SUPERUSER_ID _logger = logging.getLogger(__name__) _schema = logging.getLogger(__name__ + '.schema') @@ -1662,6 +1661,9 @@ class BaseModel(object): for field, strvalue in record.iteritems(): if field in (None, 'id', '.id'): continue + if not strvalue: + converted[field] = False + continue # In warnings and error messages, use translated string as # field name From 9a721eba322dcdf6c3a7cab27f8538bd98d4d4cc Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Wed, 10 Oct 2012 17:38:49 +0200 Subject: [PATCH 16/43] [ADD] forgotten test file bzr revid: xmo@openerp.com-20121010153849-h91mv2dzjk6i0n55 --- openerp/tests/addons/test_impex/tests/contacts_big.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 openerp/tests/addons/test_impex/tests/contacts_big.json diff --git a/openerp/tests/addons/test_impex/tests/contacts_big.json b/openerp/tests/addons/test_impex/tests/contacts_big.json new file mode 100644 index 00000000000..5dfe77ec04b --- /dev/null +++ b/openerp/tests/addons/test_impex/tests/contacts_big.json @@ -0,0 +1 @@ +[["88117cfa", "", "aa48ae52@df356352.com", ""], ["54455583", "0770708969", "75ce07b4@8c061041.com", ""], ["a4f9804e", "0794216989", "6a994598@402c461b.com", ""], ["0cfd7c89", "0788771669", "f2598da7@15f7036b.com", ""], ["220bfe63", "0767248573", "30d7e3f3@993ab043.com", ""], ["33bc0e31", "0747111853", "e86b7718@36a11f08.com", ""], ["ad3d1c39", "0707461083", "17ddff88@de2d370e.com", ""], ["06ba223e", "0793743830", "6c77d328@247685b0.com", ""], ["d457d407", "0723226290", "f497c7a2@c39adaaf.com", ""], ["b24f01d4", "0785727376", "e5c03c2c@be7b89f7.com", ""], ["666bbc2f", "0726730949", "1fbcb995@6b02d029.com", ""], ["8e29a5f9", "0769331825", "17771bb9@4e36debc.com", ""], ["330ee602", "0760467697", "86af9f82@bd3cdfba.com", ""], ["bcd48752", "0747166758", "ccc4c255@05124d59.com", ""], ["4a16c054", "0776729581", "c87cc075@6d84e9ae.com", ""], ["c065ff62", "0708945599", "41391787@d4c3369a.com", ""], ["8636e303", "0798878005", "520e2cee@3506cf0e.com", ""], ["0bdecf04", "0766303691", "7fb38fdd@46f90ec4.com", ""], ["e1ac6628", "0797335906", "5483d5cf@3372b5ea.com", ""], ["f0a24042", "0774112437", "4e99fd6b@7f727830.com", ""], ["41f18eb6", "0757100173", "f904520a@4d42ffde.com", ""], ["c4a8cde7", "", "aa344d15@3dd9bb6f.com", ""], ["abd14d72", "0778342908", "ed9cb7e3@c6fdb61c.com", ""], ["44c65e49", "0711762677", "ef6952c5@a9cd6711.com", ""], ["a3238267", "0758374338", "203081cd@e1263e29.com", ""], ["412069c4", "0745736692", "f2b989ed@67ac8c23.com", ""], ["b332577b", "0783480568", "4382789d@e1dd2623.com", ""], ["84fccf60", "", "ec2987d5@d777ea94.com", ""], ["c633db24", "0780843363", "9c6a9a39@3ba1bc58.com", ""], ["833bdb8f", "", "0c53568d@170c6826.com", ""], ["c6496a37", "0702644587", "808dc444@d3c87550.com", ""], ["3a9cf735", "0732254904", "825cb1f5@9599a25c.com", ""], ["d3bd7567", "0750010894", "3dd53f94@381f30ff.com", ""], ["ec9d38fa", "0722807920", "a63d63c9@9fc07187.com", ""], ["9e858edf", "0759964893", "ee8fc817@4969b884.com", ""], ["3820033b", "0713040363", "846b31dd@1fde871f.com", ""], ["78e3f6fa", "0713215706", "3a1bd069@a2165e02.com", ""], ["833bf332", "0732488435", "e962f524@7a67d172.com", ""], ["ebe0d200", "0791879047", "34a1610c@e30a9051.com", ""], ["e27d3d81", "0702888121", "a6c52338@56630271.com", ""], ["10423400", "0775217021", "24c5bd9d@3b4bbb75.com", ""], ["923f52a7", "0747670735", "19ee7b6b@fb8077b5.com", ""], ["fe50aca6", "0785899752", "99b1c2e4@30f03fdd.com", ""], ["bdbff3f2", "", "cc5cf567@c13f1531.com", ""], ["6dbbeeb9", "0755330628", "33efb58d@1ac1966a.com", ""], ["f88681ce", "", "4d01aa9a@461b4345.com", ""], ["8ff958c9", "0728877017", "c7327902@eae323df.com", ""], ["d8651703", "0711929482", "6aed493b@44b94bb2.com", ""], ["fcf4863f", "0721975645", "c8171bbf@2cefedab.com", ""], ["614949ae", "0708626227", "c54a3aca@bc635fa8.com", ""], ["7b0767ab", "0773868355", "2e5e7b82@bde16366.com", ""], ["ea0fb610", "0714907110", "3781b5b3@6ade96c7.com", ""], ["1833ee8b", "0754159809", "a1fd1724@0a180cd8.com", ""], ["277ea017", "0756815282", "ad93976a@022ff737.com", ""], ["67e662d9", "0789105520", "ecb672e5@df0e020e.com", ""], ["18f2288c", "", "25de55ef@a03da5ef.com", ""], ["32dcbf86", "0738646453", "88e37cac@ec308293.com", ""], ["242eb33b", "0702264598", "d132623d@6a8e65f4.com", ""], ["f2f8e50a", "0731234118", "10bdb9d0@a1dec378.com", ""], ["c414145d", "0723184859", "465e6323@083bdb6f.com", ""], ["1a6d51be", "0782912489", "7f69541f@5e17335f.com", ""], ["ef35d7bc", "0705243855", "0a8f6885@8a236f11.com", ""], ["ac48d3c2", "0714165417", "aa9c4199@1ab6a42c.com", ""], ["5ba7effd", "0731285368", "e7b91cfb@18ab2ca6.com", ""], ["c8349e83", "0711248806", "bf08adeb@72d14356.com", ""], ["ccb97edd", "", "b64dc525@fff1e0b6.com", ""], ["f6b196e8", "0754457697", "915a9cb0@75e21eed.com", ""], ["7a22e438", "0750066621", "4c2ba953@96df5597.com", ""], ["ffb4ecaf", "0791152499", "d9159dc2@6bdedc89.com", ""], ["64d655fb", "0756407008", "498110df@dfc1445a.com", ""], ["f27b441d", "0734603187", "4a23bbf1@ef4d212f.com", ""], ["2405cb10", "0761979851", "92b8250a@125bbd04.com", ""], ["fce58db4", "", "f6dfbd77@704c66d0.com", ""], ["2b16a16b", "0715217717", "d22db762@46605453.com", ""], ["4fbb5479", "0788391407", "e41e70f2@c575eeed.com", ""], ["5929e7ad", "0730836883", "38a50e2f@0cbd9fc0.com", ""], ["9fab30be", "0758240568", "f8422724@eef7fb6f.com", ""], ["57161fa2", "", "913ed6fb@1f9a591c.com", ""], ["787471f3", "0729709798", "45ed74ea@4209730d.com", ""], ["fce02cc8", "0717080949", "0430d0fa@09917e4b.com", ""], ["e5745b08", "", "2e337c3a@038a6c53.com", ""], ["bba2eeb4", "0754696079", "37c75466@05cbd7d1.com", ""], ["f072a1b0", "0774261408", "605c6350@5a7cc516.com", ""], ["95d2b9c0", "", "8935e406@c23ff41c.com", ""], ["8ac4ea3a", "0714072646", "734cfea9@fb198808.com", ""], ["5a523460", "", "2be9f139@0cf11f87.com", ""], ["5b10ec25", "0732185274", "098c0cf3@3676b22d.com", ""], ["8aa42976", "", "2942960c@1ae8ae04.com", ""], ["cdb88ecb", "0758961947", "0ea31305@77af1e9a.com", ""], ["0bd54356", "0784831852", "c437852e@c8e319ee.com", ""], ["1d1768af", "0793590304", "d66c02aa@649e09d1.com", ""], ["c19a3d43", "0749800307", "cf8fc137@761da752.com", ""], ["9c81b4c9", "", "ab84e443@09f1f4ea.com", ""], ["8811594d", "", "53698008@bcc6d45a.com", ""], ["97a8c2d1", "0715236942", "cf88f0ab@fa3f7a28.com", ""], ["fc7d6b5f", "0770870220", "b0e2ee90@41460794.com", ""], ["a158270a", "", "aaab6cf4@d31f8c47.com", ""], ["20bef3ca", "0744837638", "fca8c63c@8dae6b0a.com", ""], ["424b3e69", "", "7d2bb9c4@856ce530.com", ""], ["8ecc1d0a", "", "6df56a62@deac5fbf.com", ""], ["7e1f1b8c", "0795180887", "18d2a24d@a931a1cb.com", ""], ["4546b364", "0771517872", "77ac1ab3@3cd24c25.com", ""], ["a44b23b2", "", "55e75fcb@e20b079d.com", ""], ["90c52454", "0713222790", "e83be6ad@faaa1dcc.com", ""], ["f5fe091e", "0740461816", "fc4b6360@c84d9776.com", ""], ["01052d30", "0790737321", "421a7451@c44558f7.com", ""], ["a65634f3", "0733738433", "a7d57f5f@4f0007cc.com", ""], ["960aae3c", "", "3e2338e3@97fa70e8.com", ""], ["590858c2", "0736114828", "59cec23c@fb9704f4.com", ""], ["75725ca0", "0777217002", "5c7b484a@8c386315.com", ""], ["a16cf653", "0732018015", "a105f736@87a6c14b.com", ""], ["1ee991c1", "0759093690", "1cc3c001@d70dae0e.com", ""], ["48434804", "", "d3cee127@7dbf4217.com", ""], ["edfd871e", "0757718444", "d63d5c26@81eaeb47.com", ""], ["4fcc9a1c", "0738617045", "b114a376@9bd5a931.com", ""], ["b2001895", "0782251612", "a8868ffb@8114353b.com", ""], ["d61261f8", "0717555195", "ac613ffe@d4ee0d8c.com", ""], ["6327118e", "0739171459", "e047cf55@4920c1d3.com", ""], ["f6b14b7a", "0737868032", "418ed43d@b549551d.com", ""], ["22bc0aa9", "0759663409", "90494f38@f50ab03d.com", ""], ["3145a855", "0788697773", "8a0870bd@744b0c6b.com", ""], ["72f043d6", "0780961318", "859d6c8c@d6a29fe3.com", ""], ["98544981", "0707687449", "53a75fab@6ad934ef.com", ""], ["535ddbe3", "0751302080", "5d5dbab7@9b651502.com", ""], ["c3e5f1ac", "", "62955f00@74d50e7c.com", ""], ["6470f36e", "0776617461", "5f6d81a4@e535baa8.com", ""], ["ed39f4ae", "0766428270", "c73a731a@186ebf0c.com", ""], ["6aa1b596", "0720348914", "67d7e38b@5798f6cc.com", ""], ["f5a778ee", "0773231447", "5919b978@2768de0c.com", ""], ["9d530c2b", "0776812615", "4609c18c@aeca697d.com", ""], ["89a13571", "0744825918", "766ddb77@7dfc129d.com", ""], ["90dd9419", "0722654005", "22bef5d1@74801d84.com", ""], ["0d69e61d", "0779495819", "fa2fce6e@d3b9debf.com", ""], ["0a64ee31", "0741511488", "f48455f9@0163e8a5.com", ""], ["87f3e7fd", "0792428108", "a7763cb9@ae893b19.com", ""], ["99a66411", "0785175788", "3abf4513@c90ddc54.com", ""], ["d4d56088", "0748950722", "2dfc3b17@4af80b7c.com", ""], ["1f001808", "0733396761", "59ae4761@17a1bad6.com", ""], ["2d5cac56", "0744393937", "709feac1@1c16f3b0.com", ""], ["d0acb4ea", "0770269511", "66221771@3ff30570.com", ""], ["4f57869d", "0706718397", "1cd2ed99@0ba6e1d3.com", ""], ["a2e66c44", "", "753da3f0@41f8ab02.com", ""], ["843dc71f", "0757529013", "016ff59d@6c1f9655.com", ""], ["c314a28e", "0767460679", "cd2ff99c@8b178d14.com", ""], ["f0d6631d", "0719558518", "0725dfc6@8ce4d6ff.com", ""], ["aa522bd8", "0735252785", "57d3edd6@5eb1bc66.com", ""], ["3f69f804", "0717000930", "6ca23f02@d169a50e.com", ""], ["cc98acfa", "0796356276", "19579c3b@39e71005.com", ""], ["9e6dbdc2", "0769660138", "6349577f@fba33845.com", ""], ["fba0618b", "0758705886", "ae7a9890@e776386f.com", ""], ["f36d563f", "0744525165", "c76184f9@5dccdf5b.com", ""], ["d2df6db1", "0783917910", "f7726c1a@c9e9b839.com", ""], ["8645707d", "0724081473", "842f2369@be456fbb.com", ""], ["95b4296a", "0782845149", "af879705@e8e7bd93.com", ""], ["345ba50c", "0792384168", "ca3ccb91@faf01bf8.com", ""], ["e05de824", "0707660173", "79f09a3d@3deafa86.com", ""], ["de837ee8", "0772243198", "b25275dc@f057ff78.com", ""], ["ae9f1402", "0704963747", "253d5995@6c771579.com", ""], ["259c5532", "0781521724", "cec33738@874afdb1.com", ""], ["1bc82378", "0748931231", "7b3feccd@cd205def.com", ""], ["c73869a0", "0728684210", "790c4825@22921089.com", ""], ["725a20ee", "0716516714", "a5852a47@85846987.com", ""], ["f975f39e", "0732891142", "aea8acea@a6feaa85.com", ""], ["d0fbcd0b", "0753684058", "78f9d7f2@49082702.com", ""], ["d41da511", "0765105657", "bca42072@50aa0f9d.com", ""], ["42be8058", "0776465871", "15d0f988@805b0cb0.com", ""], ["269eda62", "0705841239", "67dc4d59@51389f5b.com", ""], ["2ffc5c2c", "0732437371", "c3b76c4c@9772ea3b.com", ""], ["258e1957", "0768636603", "476831de@98414c35.com", ""], ["a18a68f6", "", "136d0b0e@15864440.com", ""], ["56b9197d", "0734391703", "ab36de76@d7a2bc96.com", ""], ["e1acdbc5", "0790910000", "867827b4@fdffc111.com", ""], ["21e48ef2", "", "4a1bbb5c@29b1ac3a.com", ""], ["aec1a0ea", "0749338116", "64dbedc8@6e81a330.com", ""], ["b1afbf53", "0701600188", "5f1e3fcc@0c6cba23.com", ""], ["bc7e214f", "0725934639", "369caec7@ba1df297.com", ""], ["dd059d1e", "0774510615", "bac60ace@a354f53c.com", ""], ["cbe822f4", "0745859979", "01bebc6e@735bdad1.com", ""], ["961d21e8", "0741358098", "9348702e@ce9c1230.com", ""], ["c8ae2583", "", "3682409d@a15621c4.com", ""], ["c326d443", "0784986501", "0a505045@96938424.com", ""], ["3e51bb13", "0764438578", "b713c235@8e350663.com", ""], ["ad38c924", "0772825196", "ce8d0f19@b2d4ad9f.com", ""], ["65bd5021", "", "3f6318d5@91f006ed.com", ""], ["efd2705b", "0770828242", "736fe829@7c30bc29.com", ""], ["6ebdfef1", "0710559084", "e035b963@a163fb9d.com", ""], ["021358ef", "0711112569", "ecc20e51@547a7cc2.com", ""], ["548d9a59", "0790101525", "d42aa9de@6f91f22e.com", ""], ["262f96db", "0780153799", "094258c8@e03d9795.com", ""], ["a548913a", "0779869470", "6a6999ca@ca0a88d1.com", ""], ["e7beec03", "0796588859", "5e9940fa@eca859cc.com", ""], ["ae5a8077", "", "5ab3a073@18a45934.com", ""], ["eae22551", "0747781534", "6b04366d@7653fc99.com", ""], ["2fb9da57", "0799726898", "895646f6@2a66fe2e.com", ""], ["51d83f2f", "0704387855", "0d2ea467@62feebfc.com", ""], ["3da4bc6d", "0786428049", "db6a479a@37899df4.com", ""], ["c08101a4", "0751956133", "67852281@0bb68d55.com", ""], ["b5f8c156", "0754878556", "bad4eea7@4d5a817a.com", ""], ["ded80b77", "0772054646", "ebb2cd45@54262aec.com", ""], ["7b086cfd", "0776426442", "3b503434@d46b44fe.com", ""], ["7a64e1e8", "0740165572", "e9d50a03@38375fce.com", ""], ["ceaa584b", "0791796518", "b5839977@cfe3ae10.com", ""], ["9c4a8683", "0790187277", "f8e4fe02@02fc1038.com", ""], ["193f99b7", "", "915c1574@50758bc3.com", ""], ["6fb6c30a", "0720403051", "f4bcd3ca@a8ab8216.com", ""], ["9486800f", "0723518103", "a3886d22@6600cdda.com", ""], ["6e09f6bd", "0707095889", "868ffc60@46eb3135.com", ""], ["da1bd13c", "0756382253", "f34abc2c@d18ec367.com", ""], ["3910ae7d", "0786894789", "a8a751a5@9ddf61c7.com", ""], ["32e5be0c", "0738208660", "eab918a8@aeb7a8fe.com", ""], ["c6582498", "0744023415", "b893479d@9ad5c44f.com", ""], ["0c59f520", "", "d552d0fe@8675edcc.com", ""], ["3458ee7a", "", "c1145be6@4043406c.com", ""], ["91fad8e7", "0701771702", "a13640e5@2370e9a4.com", ""], ["2230118a", "0792290165", "529b9444@17c21068.com", ""], ["2df4bff7", "0783219548", "99a9af11@8e5a539d.com", ""], ["0a752397", "0723553802", "b094b6c9@2d8fb162.com", ""], ["918f92a0", "0790485640", "3984cea2@0750289f.com", ""], ["ff811166", "0756833896", "960e71f3@6ded22f4.com", ""], ["6578cd85", "0791455734", "f7e56f2a@35a68ac6.com", ""], ["fd21c33b", "0794847916", "23ae4c63@51515e19.com", ""], ["a12e519d", "0700174532", "d3b4264d@0c9d62f1.com", ""], ["96827dcd", "0780494940", "d70d9ab7@df66a83c.com", ""], ["8cd46009", "", "a8addc7c@c899c5f7.com", ""], ["982fb2ee", "0778368795", "b99de484@d262779b.com", ""], ["dbe5ea51", "0756105541", "ad90b51c@3f1af6d8.com", ""], ["3d963533", "0761709058", "85b32c2c@d99341a7.com", ""], ["064f2df0", "0791023088", "45d63e8f@ce0852ad.com", ""], ["e93edb9d", "0713294122", "b9b82603@a226aaba.com", ""], ["a94ada15", "0796377896", "5dd00c2d@794bcc18.com", ""], ["7de0f3db", "0748976042", "7714652c@3ef8b9d5.com", ""], ["6ff485d2", "0774873001", "77b5b11c@675bf1f7.com", ""], ["e472c175", "0761539211", "dc30792d@00a0ef22.com", ""], ["7a467ead", "0734394099", "e8d5a021@93926f82.com", ""], ["87749969", "0780899950", "dfe454aa@94a8fd44.com", ""], ["c24c8b06", "0777380273", "98dc8782@26872819.com", ""], ["a059abab", "0753758924", "581281a2@890e7bc6.com", ""], ["3b85bdd1", "0784193150", "cf3c82aa@e5df6c1b.com", ""], ["7cbc188d", "0704027570", "c6444b77@8ab56b47.com", ""], ["12ff649b", "0755744514", "a7ce16ba@c054924a.com", ""], ["cc77e0c9", "0724057991", "4e2473d1@4aca6e0e.com", ""], ["026e42e1", "0741456583", "09906c46@e32bed7b.com", ""], ["9e6550e3", "0740725000", "27ddd3ff@c0891335.com", ""], ["700be741", "0715655544", "bcc93b4c@de8854d5.com", ""], ["f15ce066", "0770746966", "aff2e8a9@19f37ff3.com", ""], ["d56faa79", "0780216109", "7119d43e@10e506f6.com", ""], ["d0d97e5c", "0799577373", "9c131514@a5f6fdee.com", ""], ["d3320f24", "0789091392", "e9b8d4a6@e2c9fa38.com", ""], ["c9b3e28b", "0753027473", "d4c85b77@26952326.com", ""], ["5f81787f", "0789741665", "ca7dbbcc@97fc237d.com", ""], ["dd720b39", "0799154501", "9cc0df66@70c2f5d7.com", ""], ["f584f902", "", "38a024f0@27413ff3.com", ""], ["bb37782d", "0788379130", "01ef864a@191fed62.com", ""], ["f6964d05", "0732597248", "32d25b28@6ae6e905.com", ""], ["cfdc9a87", "", "f453b3f6@4ccf20a3.com", ""], ["2c6f8592", "0773589537", "d71824f7@91dd37aa.com", ""], ["ba101360", "0748932503", "674abbfc@81b3b946.com", ""], ["3b35863b", "0759392115", "c249bcc4@e650abe8.com", ""], ["610da7a5", "0712310446", "f4a25391@d7759c5f.com", ""], ["1557e96a", "0796582698", "4aaf8b75@99c557d5.com", ""], ["98519b81", "", "a5050e55@917cd419.com", ""], ["ed820127", "0754946950", "dd82c2c1@7b1ab977.com", ""], ["b78147ee", "0765494006", "53795132@dd38aec7.com", ""], ["3d4859e8", "0782874657", "67b89627@d2269d32.com", ""], ["ce2366c6", "0719814269", "c6200859@0788dca5.com", ""], ["fba51219", "", "b1d107dd@b664bba3.com", ""], ["7469e033", "0742202666", "9cbedb4b@31ba3795.com", ""], ["20c151e6", "0746146553", "90851479@e8a420a4.com", ""], ["14499acc", "0731779256", "c5c930ab@8de927e9.com", ""], ["71297ace", "0771197563", "1bf4547d@f5c8f5b3.com", ""], ["ea630ed8", "0712073290", "245f14c9@9470fedc.com", ""], ["2c54ae4c", "0767896178", "76f988c7@07ade1eb.com", ""], ["e143bc1c", "0798189886", "68717fc3@c4ca3101.com", ""], ["1626281f", "0782558075", "d87d65f6@585b48b9.com", ""], ["3df7b85d", "", "4ef416c6@0e39f805.com", ""], ["101c2c3a", "0754160899", "c77a5ff2@e3dbfd0f.com", ""], ["0e433acb", "", "ce18b2e0@18a803fe.com", ""], ["b454958d", "0758649506", "daadf940@35a7b0e0.com", ""], ["54dd80e3", "", "499da17c@7a6a8e16.com", ""], ["c26e8c0b", "0705458229", "816d4c62@f196940f.com", ""], ["d6c7c2fb", "0712740449", "eb0f3861@028419f0.com", ""], ["ca236785", "0710117939", "74d89d0f@9681f6e2.com", ""], ["03e89273", "0770985481", "a767a13e@202e1c7e.com", ""], ["df528ea6", "0715236243", "4451e8a7@ab9e9cfc.com", ""], ["b369c3a1", "0720792641", "af8e60fb@affbf045.com", ""], ["0e3b3dcb", "0726931283", "b6c80dd6@377de27c.com", ""], ["72f56e12", "0749900046", "dfd0d53e@a04c2e07.com", ""], ["0b616a06", "0788777560", "e520ec34@5a88ce36.com", ""], ["7dcd72da", "0727744886", "c7ecb909@efc93b54.com", ""], ["b6749523", "0755931358", "76b2e187@777e9047.com", ""], ["349f347f", "0772053293", "aed364ad@8f6c1755.com", ""], ["b0a24d58", "0737012558", "b013a7fd@a6d27266.com", ""], ["924dcf44", "0761967844", "539d93ff@4e1741bd.com", ""], ["90781369", "0745086457", "bae1c94b@df7075b3.com", ""], ["7bbf6e08", "0762583195", "c54b4a41@f08e61ec.com", ""], ["696d9ce3", "0753759660", "9be913a2@efd894d9.com", ""], ["32dea399", "0730020519", "b76d0594@9127aaf5.com", ""], ["4aada6ab", "0716703652", "1172d8f6@70029987.com", ""], ["c2b450aa", "0786756810", "05fa85d3@6305e8e2.com", ""], ["f7220cb8", "0776056046", "b14c6af3@ebfcf7d3.com", ""], ["7da05a35", "0741271883", "abe23d1a@35fc89a5.com", ""], ["b277cd48", "0703025387", "ed28c019@feb1fe78.com", ""], ["55a16321", "0740030335", "a3db681b@de92450b.com", ""], ["646ff9aa", "0777457443", "ea0704a8@d6c557b9.com", ""], ["557c863d", "0752298537", "c412a0a1@4db33621.com", ""], ["324da6d3", "", "61e7386a@d4a90843.com", ""], ["144835ab", "", "70744ba9@000ee58a.com", ""], ["eda25157", "0734918749", "efdbfb3e@83133e46.com", ""], ["bf5d4b09", "0712427566", "4b13f413@18c93b1c.com", ""], ["c3745e8a", "0723463789", "e5ccf0c5@00118e4a.com", ""], ["11c0a9da", "0778743010", "865392aa@1c8da31d.com", ""], ["7d0eac7d", "0701997984", "f2990151@e8d055f3.com", ""], ["e701488a", "0712599226", "f0d54316@037ea274.com", ""], ["acac2543", "0771603428", "73cc7388@981dc1a0.com", ""], ["82f371b0", "0758909949", "6ebe0478@2b14af27.com", ""], ["896bb3a8", "", "83158633@aa3fe414.com", ""], ["9ce45cf4", "", "38772abe@078a889f.com", ""], ["fcb5a33a", "0769530741", "29a4e798@effcb6ef.com", ""], ["116d4f92", "0786255405", "e78e003a@cf7ebbc9.com", ""], ["35e5bd6c", "0700694228", "399e4cd6@eb7a9b08.com", ""], ["71df9408", "0798109987", "91433ee1@b3240820.com", ""], ["294e2aa4", "0700947677", "abca0340@4cd61d52.com", ""], ["9553d892", "", "3af85bbd@90ee53d6.com", ""], ["bbcdb933", "0773940915", "de196282@bf4432fe.com", ""], ["3ccbc198", "0742527356", "014618cd@78b4016f.com", ""], ["100d9496", "0786481835", "df9a72cc@123af588.com", ""], ["57cfe177", "0789499193", "f5f0de36@27d22e23.com", ""], ["cdffca88", "0714652673", "4102e3e2@26a0d112.com", ""], ["69c5f300", "0762631167", "14f5940e@fe984955.com", ""], ["819e988a", "0798148053", "b78ed8c4@ef4a213d.com", ""], ["f5c6a92a", "0774167414", "c996df73@8a8a0586.com", ""], ["3d3f9cc9", "0766973558", "f0a43735@47580cbd.com", ""], ["a30056ab", "0787961025", "04256d2d@459d56d8.com", ""], ["7dc0d98e", "0741838356", "2a597e04@f7089dd3.com", ""], ["e19f381b", "", "ce9c237b@4ec0fd84.com", ""], ["34b3c355", "", "77bcc11b@2e791c9a.com", ""], ["4bca2052", "0799612575", "dba5fc2f@600d7b82.com", ""], ["dbc71748", "0726348854", "efc05aff@df67c9ee.com", ""], ["6fee2352", "0786598805", "c8bad10b@f550bfa0.com", ""], ["b56c589c", "0722452846", "43e2ed1c@4e233028.com", ""], ["77f4b627", "0795147567", "6f26728c@015d1c78.com", ""], ["e6e1b439", "0720916434", "6cf4f2cd@d0daef5a.com", ""], ["c4e49a4c", "0787210516", "bfc6dbf7@8f315a9f.com", ""], ["3f717509", "0704228943", "7a9fd98d@112a27be.com", ""], ["8c0f2abf", "0739504671", "b2c6a55d@bf7bb207.com", ""], ["d0ec34db", "0745702321", "4a426510@7c7cc4ba.com", ""], ["cfe0f395", "0708421594", "df815b04@d27562ee.com", ""], ["4ab6a603", "0781614226", "8e7498c4@216eb70c.com", ""], ["53727fa1", "0778595217", "0fef75b6@7e3eee41.com", ""], ["1dd3c6c9", "0795888769", "c88ef6ef@9182a8c1.com", ""], ["5dbdf1c2", "0700853188", "8b2128ff@5a2fa2e4.com", ""], ["5cbf9768", "0752068105", "064d2e97@f4631505.com", ""], ["b81ffbce", "0737952148", "ef90cb7e@7f7bb2d3.com", ""], ["6c9426b4", "0765528647", "571533da@097b853a.com", ""], ["76b87a42", "0735376294", "4fab1c8a@1004c035.com", ""], ["cc67d10f", "", "df05e09c@10cdee9b.com", ""], ["91f2c079", "0727155396", "f70f99d0@e56d0e68.com", ""], ["7687c589", "0723819703", "f4a37237@8306ec54.com", ""], ["cc065203", "0777023625", "235ab1ff@ad59efef.com", ""], ["ada410b8", "0795063396", "d58735f0@1cfd2246.com", ""], ["b17fda79", "0761763470", "06024431@3a90405d.com", ""], ["82c9cf46", "0795927937", "cc7d157e@fe591e61.com", ""], ["1229f23f", "0766794577", "196fbca7@2762695d.com", ""], ["41ee3436", "0751875418", "e789384d@1e8c078b.com", ""], ["b59b1ae3", "0706674618", "eaa7394a@9ca599d5.com", ""], ["32a1572d", "0739902105", "076df342@f7257d5d.com", ""], ["87142aaf", "0773843071", "2d1ed5d0@745442e0.com", ""], ["fd302180", "0752363053", "69d00e9f@4361b9c7.com", ""], ["9b129111", "0711208227", "e3f41ba4@230ff3c7.com", ""], ["00c627e0", "0795414341", "878530d9@891a7047.com", ""], ["f9bd3b66", "0754134870", "36b72937@8fe2912f.com", ""], ["d89c11eb", "0760560969", "6ec530b9@d7f30799.com", ""], ["e2b6ef2f", "0711238958", "6250bb8e@23e30295.com", ""], ["811c4293", "", "81aeac97@4083c108.com", ""], ["96c90ed7", "", "19727897@698b879b.com", ""], ["b82a09af", "", "7527ccbc@4e351f9e.com", ""], ["17e61a75", "", "ffdd4947@541dcc9f.com", ""], ["f97fa25d", "0749784894", "8b419430@4ae116d0.com", ""], ["13b9a7da", "0748292813", "1d2e437d@1f7fd137.com", ""], ["e0879369", "0778630426", "fbc7e460@350c1a55.com", ""], ["1a74805d", "0749531731", "f06e561d@ebbec1ed.com", ""], ["eb5db9c6", "0707095397", "ec71e260@1ab509e1.com", ""], ["9da2dc8e", "0717471756", "23418425@2dffbc70.com", ""], ["a6f6f46f", "0703721541", "037414ed@c1759536.com", ""], ["765a5633", "", "4fd61a7f@e9fc7672.com", ""], ["3a717722", "0785655203", "82f8e536@4aab3d98.com", ""], ["34b938b7", "0712389450", "0ce3ed1c@8d5aec4e.com", ""], ["fa4eaa80", "0760264228", "487d193e@86c36a2b.com", ""], ["6ba54ee3", "0737464908", "ee4791dc@6e7e18cc.com", ""], ["3d02d6e8", "", "b2c13ef3@66a3948e.com", ""], ["ee3c7b2e", "", "2c51c4fc@97eaabe3.com", ""], ["943acb8a", "0775102341", "306ebc20@ad6a778a.com", ""], ["e86c14c3", "0705424432", "40c1518d@76aab128.com", ""], ["0d993e76", "0729633289", "a18fcc70@f04ad1c7.com", ""], ["fec3f43d", "0740081762", "e04dd4d1@615c78e6.com", ""], ["90645f43", "0737009757", "d8ed6970@a5baa38b.com", ""], ["723c41aa", "0732060795", "d42e8f6c@9f01caf6.com", ""], ["96235675", "0755010192", "30d98b6f@fad581d8.com", ""], ["1bfda78b", "0736195102", "f0ba5711@24ec477e.com", ""], ["9fde0750", "0737641391", "f73cf50d@108aad37.com", ""], ["4cf28ec2", "0715717575", "f18063da@f0625607.com", ""], ["96824bd2", "0754345078", "f7b7534d@5c430ea4.com", ""], ["57e1a524", "0775075206", "9b963917@f4c93815.com", ""], ["1a1b212d", "0796156113", "0da3bd5f@60d5c0a3.com", ""], ["73267af7", "", "20364468@e070797b.com", ""], ["c4fcd602", "", "c996b53c@8c9e56e7.com", ""], ["23944725", "0756128345", "3f1043b9@4e705983.com", ""], ["8665f6ee", "", "c562cd48@d44eb942.com", ""], ["5178e72f", "0791852636", "bbcae5ee@428b8ba9.com", ""], ["9de66e8d", "0753385146", "55874b2c@f115714e.com", ""], ["de112089", "0734935576", "789b1706@6d2b21b8.com", ""], ["4e8283b9", "0700426636", "081d48fc@797f0558.com", ""], ["081d4271", "", "99829160@285f8fd8.com", ""], ["69dd706e", "0771351294", "906cc914@01cacc1a.com", ""], ["5de2de11", "", "1edc2268@e01bd776.com", ""], ["e823c600", "", "ecda6d9d@74a5da65.com", ""], ["907e7635", "", "14cbd73f@3c5029c4.com", ""], ["d6236d57", "", "39f3d4ae@1b7b8200.com", ""], ["aa2630bb", "", "75535cee@5391b394.com", ""], ["c6cda97f", "0710755065", "ff4ab3b0@e7638240.com", ""], ["b6661f85", "", "067496c8@1c3d2df6.com", ""], ["c95cf777", "", "afa9a3cb@279aa011.com", ""], ["e575e109", "0785713068", "8601eb2f@7d69d9ef.com", ""], ["c4aed4d1", "0797133251", "0ebd0ca0@44b0db13.com", ""], ["d8799ad6", "0766336482", "c8a2db59@fc20467a.com", ""], ["fe041483", "0747021458", "f1341509@a0d4bfcb.com", ""], ["9c8975f4", "", "1adf2f38@6ef39e01.com", ""], ["fc572eb6", "0707977477", "39ce98ea@c55bd59e.com", ""], ["86b7eeac", "0714499420", "19318930@f8cab44a.com", ""], ["d64977ad", "0761071885", "11a682aa@69ca2391.com", ""], ["ea006f73", "", "dfa103d4@ec0cffab.com", ""], ["0137c08a", "", "4e98808d@f9cbe2ee.com", ""], ["4c3ef173", "", "ad18e541@9c961399.com", ""], ["3a90243d", "0702089544", "73d9e758@a8185f82.com", ""], ["552fcf54", "0711153208", "b425511b@1f62462e.com", ""], ["47f20958", "0709287769", "387a2338@24efeaca.com", ""], ["95568c92", "0753062212", "230b16d8@7c47aa21.com", ""], ["4e00e50d", "0705656877", "e0557aa4@17b9f5a3.com", ""], ["6a744fbc", "0752791693", "f1cb0467@fc01b034.com", ""], ["b00241b5", "0707154247", "8fb98d05@46674fd5.com", ""], ["40bbff33", "0798115729", "238dcde2@3a402b74.com", ""], ["2ff0e839", "0734208035", "b85bc3b0@fb7c2ace.com", ""], ["48db2fd2", "0746707903", "21bd36a0@967fc046.com", ""], ["82ce2569", "0712225501", "b578f1ac@ebb9345c.com", ""], ["bc8eb82a", "", "579a208c@3a587c16.com", ""], ["e6c2ceff", "", "cf4f1954@a02d98a5.com", ""], ["36cc5274", "0794054613", "6a6fba68@59b878ab.com", ""], ["a47d46b0", "0779573055", "7e3b995d@8ba62af1.com", ""], ["abc2eea3", "0770887487", "2d0b55d1@9b58d90f.com", ""], ["3aeec47e", "0798128924", "cc01849a@23ae0c8c.com", ""], ["438153a3", "", "fbf57f49@b868a49c.com", ""], ["c2e79acb", "0790684868", "eefb75e0@e0e96a2f.com", ""], ["975b014e", "0762757057", "aa6a37dc@a30b0c72.com", ""], ["e610d666", "0781184369", "4d3f4b20@97d8a401.com", ""], ["73495ac9", "0719719687", "7a018884@c2c13e7c.com", ""], ["9a445855", "0785545967", "2d196afc@ffff6d02.com", ""], ["ce2ab926", "0700868262", "2cd6763f@b6d56901.com", ""], ["c0fcbafb", "0706968744", "8ab42ade@5692f6e3.com", ""], ["0604c52c", "0772596476", "b71e2f2d@31e533dd.com", ""], ["b4fedfd6", "0700359913", "af1c4ae4@fcbb6631.com", ""], ["1e19fa35", "0742671783", "009e471f@8e4f29d9.com", ""], ["6bc8631a", "", "38a6dc3d@900b535a.com", ""], ["09c221a5", "0712627371", "6b081654@a5d250ba.com", ""], ["55d80d5c", "0710423548", "149c66a4@0adbe042.com", ""], ["bc6f4369", "", "28372894@dfa1fbcc.com", ""], ["7aa8a6a4", "0766166352", "9dbb5873@5731fed6.com", ""], ["5c781ef0", "0790623819", "2d629650@b57c316a.com", ""], ["6830c71c", "", "39897b3b@7ee8cde5.com", ""], ["6af8ee98", "0747825215", "5b56cf20@4c81a082.com", ""], ["5a8bcde3", "0763197608", "e69532c0@34630f3b.com", ""], ["8f9037fe", "0726240186", "df9e5814@e4fc4423.com", ""], ["fb13d5b0", "0721173617", "d475522e@c02f6cd8.com", ""], ["9ce57e77", "0774209520", "288a94c3@2a0fba22.com", ""], ["b54fd99f", "0735610926", "061e00aa@34301c70.com", ""], ["fe1a320c", "0776730222", "53770fab@f7c2cd27.com", ""], ["10c3b647", "", "ae78b399@9c814e5f.com", ""], ["d24be5da", "0797581905", "9394a573@bc5e7a67.com", ""], ["e61273a7", "0730217168", "ef553bd3@fbd3732e.com", ""], ["fb6472b6", "", "cdf00170@f2e89868.com", ""], ["b588bcf8", "0715004480", "21505721@1ce9749f.com", ""], ["36ef226a", "0720093875", "293e85b5@c81c476f.com", ""], ["007c51ec", "0770116496", "b1b68fa0@159369c4.com", ""], ["949c6074", "", "6e66fd1b@434382f3.com", ""], ["4e4f11d2", "0762955197", "66d29d82@531b2e83.com", ""], ["758c58d1", "", "9dd36cae@295af2b4.com", ""], ["99be0c22", "0794398863", "0249fb4e@4786d822.com", ""], ["1298fb9d", "0720206151", "d3c59051@5d59357f.com", ""], ["b97f877d", "0779925184", "f6414a10@13ffe42b.com", ""], ["3e653aa1", "0750192219", "b35e6fe5@1b6bf57b.com", ""], ["8fbab13c", "0732153634", "b0a7711e@52ecc07a.com", ""], ["1a3e75c1", "0743946942", "25786349@8c65834c.com", ""], ["6ca639a0", "0717692914", "ad3be222@b10a9147.com", ""], ["3c8dd318", "", "bcccd36e@cc70d14f.com", ""], ["a6d3e7cd", "", "aa9c8a70@963098a4.com", ""], ["6fc1b3a2", "", "d19b5d6b@934e24f1.com", ""], ["759261f1", "0709242692", "72f51517@87b24cae.com", ""], ["3d7a1d1e", "0715207809", "877c9255@1671ff51.com", ""], ["93278583", "0742390782", "21dca350@2cb6d4ef.com", ""], ["51f96553", "0748353883", "6be176c3@4e96ba7a.com", ""], ["a347fde6", "0741522657", "c133fafa@26d07149.com", ""], ["362bd97e", "", "60ac173a@10183062.com", ""], ["b1a7c0ff", "0764817452", "67e54fec@06bf6f87.com", ""], ["9ee4aa89", "0741377765", "1856ef66@cc9f3fcf.com", ""], ["49a7dbd1", "0783985348", "ce3970b4@ea82bad4.com", ""], ["206b0643", "0767461177", "a897ff67@8adf5df6.com", ""], ["596a240b", "0722068325", "82663950@9a960206.com", ""], ["dc852f2d", "", "381e1687@8bc841b8.com", ""], ["44dbefa1", "0774987561", "391ecd0c@26e9e890.com", ""], ["69e6e40c", "0713319482", "978839d0@5ad63633.com", ""], ["0121d5a7", "0714766437", "8abfc0e0@b6e025bf.com", ""], ["cd5200b5", "0764514081", "6304987a@1d2f7eb9.com", ""], ["536c4831", "", "c0e01f58@cc7a1d56.com", ""], ["31167693", "0712547344", "9e54c7e9@90b5ff9a.com", ""], ["6a240068", "0776430272", "4ae0eabc@345cd527.com", ""], ["c14f594b", "", "7351d1ff@31c0438e.com", ""], ["b290c6f6", "", "9368d514@98711325.com", ""], ["f870b7b6", "0789240289", "4f8593ba@f80fe1b6.com", ""], ["f9c1a49b", "0714414302", "02efd04f@a4f155cd.com", ""], ["51605326", "0704713807", "13a21489@ec8993c8.com", ""], ["88b5f2e2", "0794091627", "a2745c7e@a32c3b1d.com", ""], ["f3b6a422", "", "4e024c4c@022f5e1f.com", ""], ["c2cfc88a", "0761247770", "153b9fe1@9859b8a4.com", ""], ["597f3524", "0761882451", "1982d242@a8f03583.com", ""], ["d694bc4c", "", "864732d7@76277920.com", ""], ["8e859b4a", "", "d16f7f69@0261a0ef.com", ""], ["2d583afa", "", "876b76c6@e55415c1.com", ""], ["1bd3b1ae", "0761041162", "a2ebbee4@9a1b4f54.com", ""], ["a01d3c8b", "0703532365", "d6d7b818@bf578a2a.com", ""], ["6b8ff331", "0705773074", "e1e26a20@144139a8.com", ""], ["bef24803", "0750088813", "fde0039c@4ed43033.com", ""], ["253651f9", "0715013226", "3aebf85d@f7455c19.com", ""], ["34da6189", "0764808908", "03f2bd4a@c73ec94e.com", ""], ["c72f3422", "0754811376", "5941e3ce@769439b1.com", ""], ["1761f923", "0708249762", "20e78457@68b640c2.com", ""], ["a7ddd99c", "0754994431", "ad342d73@622fa530.com", ""], ["1b32b5cb", "0769400739", "510cf15d@1feaf7a5.com", ""], ["2ecd5127", "0788462377", "f2a96f71@c6dadef6.com", ""], ["b2e338ca", "0780233422", "0394850d@c7f808dd.com", ""], ["0999515c", "0708264022", "9214c832@73793633.com", ""], ["b4662820", "0791822790", "378843d9@faad02b6.com", ""], ["b6526e56", "0702391559", "ddd3922e@683a9bc3.com", ""], ["e1d0dfca", "0796434690", "bbea0324@a25f259b.com", ""], ["7507ff74", "0718970953", "957feb53@dd898780.com", ""], ["537fba55", "0712047297", "380f1934@1afa6994.com", ""], ["c03244c8", "0719594534", "d685945b@5f863d81.com", ""], ["7a0b4463", "0726964918", "7dada8d2@632a578b.com", ""], ["01f105bd", "0771635491", "0103cc3d@2c004125.com", ""], ["7c9baaea", "0793949807", "b0aae8e3@23796a56.com", ""], ["05f636c7", "0707511947", "36c7c75f@2d86967f.com", ""], ["71d917a2", "0784386161", "ad18b2df@9366e77f.com", ""], ["d417a204", "", "e3ef2306@48c768ce.com", ""], ["8d6d1bb3", "0705778200", "ee43e84c@f6df11fa.com", ""], ["f4c05035", "0772727935", "c0ced236@b4ea187c.com", ""], ["e6d75e18", "0788952581", "402b9e4d@0b37e829.com", ""], ["1dabeec9", "0727510448", "a0fac12e@0ea007ab.com", ""], ["f71fa168", "0759368909", "2b111301@22da70d4.com", ""], ["23b7c929", "0786848255", "b71bbd84@857f9ec8.com", ""], ["a085c14c", "0740213711", "d32148b7@b67a73ae.com", ""], ["9ac9fa3e", "0701707031", "42cbdd1a@175f318e.com", ""], ["b4e40dc9", "0715864237", "a15c733d@6669b4f7.com", ""], ["94020cfb", "0715193084", "ad45863a@2eaab246.com", ""], ["626c1ee7", "0750957414", "d66ae397@2e895e2f.com", ""], ["1eabc823", "0731567529", "20f8332e@b4b0703c.com", ""], ["e5a98097", "0718031536", "718b7ae2@8504fe2d.com", ""], ["69245c65", "0779488230", "fc645fef@635fbdcf.com", ""], ["247ce107", "0721718937", "2c662519@b0c33ecc.com", ""], ["c4925e4c", "0710565488", "36e178bb@4cb2efd9.com", ""], ["d0ef8f45", "0725811338", "f5404bce@9e12cd0a.com", ""], ["ed80a2b0", "0708451045", "6b9f4393@5ea5c209.com", ""], ["13aab094", "0711289294", "ce0972c1@c202849a.com", ""], ["0154dcda", "0734530650", "e225ca87@12039c6d.com", ""], ["004cecdb", "0741339169", "d85299ff@ce7f5fd2.com", ""], ["bbbcbeb3", "0723560169", "e31d0863@b105423d.com", ""], ["cef7c1a9", "0752786167", "3c76459a@4e492e4a.com", ""], ["c3cb922f", "0779170714", "24e2fd0a@cd086652.com", ""], ["a267897d", "0740299573", "c8ab2622@5d1d5225.com", ""], ["f73c9d67", "0754733442", "433436bc@eaa41a7e.com", ""], ["bb0022c6", "", "016e3dfc@ea644733.com", ""], ["060c6c8f", "0767126800", "24a3b50a@8c364ded.com", ""], ["9f5b5d9a", "0769176462", "20dc7747@ef1ad5b9.com", ""], ["43e67ec3", "0743483082", "8a5f94e8@5e7d39a0.com", ""], ["345e4a33", "0787677047", "3a0c802c@608dbe31.com", ""], ["ec2e7f7d", "0784990090", "23ec5d05@c4bebf51.com", ""], ["e239c87d", "0738679004", "8cfa7063@bcb2bc21.com", ""], ["271fc9e7", "0778470494", "c2d46c92@dcb3dbfc.com", ""], ["3b0585bd", "0795454441", "40772365@1d8f7187.com", ""], ["a1a58875", "0718370412", "2eb10d10@5ec547bd.com", ""], ["4c45c47c", "0739708464", "81d5c8ac@06447b51.com", ""], ["e57e8359", "0751620730", "4b2b8980@c95b69b5.com", ""], ["735270e1", "", "ee37dbee@25a4ff65.com", ""], ["4b44e00f", "0711946373", "0eb6a22a@b320af8f.com", ""], ["8a372c1e", "0770968660", "d6678940@ee818cf4.com", ""], ["88efebf9", "0720205646", "7d6d6bcd@87fecbbb.com", ""], ["6ebf214d", "0780223716", "d28759e2@e323f23f.com", ""], ["2eb46280", "0795896930", "8dae7897@d3854b08.com", ""], ["53bc97c3", "0730645626", "12518a5f@a1fdfaca.com", ""], ["fec1e211", "0754874523", "40655f66@4897f637.com", ""], ["1b8ef046", "0731698966", "cbed8d9a@893a95be.com", ""], ["70099e91", "0725267567", "b75efdd0@8dcf7f96.com", ""], ["00234cba", "0748766899", "9088d159@a1a1f4c1.com", ""], ["35ff23c6", "0747870199", "c961250c@d8a06d24.com", ""], ["9e7417f8", "0746801322", "ba7e50e4@cae69ff0.com", ""], ["3428f8d3", "", "56c21910@48bf2959.com", ""], ["f575e8a6", "0734183553", "4953d608@e2885866.com", ""], ["ee701421", "0762346436", "0eee7492@884b62a5.com", ""], ["197c760a", "0741186587", "8e5f6e2b@ea3cb8da.com", ""], ["691a46aa", "0708195042", "cf89f958@dfa6105e.com", ""], ["b03db786", "0716744355", "3f1c117f@a6c83666.com", ""], ["4be4d96b", "", "48195284@40467121.com", ""], ["dfcd1ada", "0783073475", "9785f27d@1df650f2.com", ""], ["4490c17c", "0786052205", "8e4bab0c@b98b8c4c.com", ""], ["f4e991f7", "0717102345", "e5a0a4fa@0483a00c.com", ""], ["b15003cd", "0771528694", "e012bb67@b1463416.com", ""], ["95e2a50d", "0796484412", "508dbc66@25ef9b72.com", ""], ["c2003313", "", "8856d03c@72d7544b.com", ""], ["276b4bc0", "0789810903", "ef87caba@2741b9cb.com", ""], ["15da925e", "0748450892", "656949ba@78234623.com", ""], ["77e171e3", "0786551610", "a86b4d19@110baa19.com", ""], ["9d9d0504", "0758342738", "0dc0e069@bb5e721e.com", ""], ["f4def247", "0798052097", "e66df9a7@af41f423.com", ""], ["c3ea412f", "0706797533", "4e28af08@8a754fb6.com", ""], ["53812400", "", "6c6215f1@45e33532.com", ""], ["369bfda8", "", "45cf4bdb@6d0980f6.com", ""], ["dbd3b821", "0765939770", "f448784d@35d292f5.com", ""], ["142f350f", "", "41c305cd@fdf34481.com", ""], ["8fcd1626", "0770444423", "323d955b@41ff7d99.com", ""], ["8789a603", "0751524803", "359bc02a@72af402c.com", ""], ["0a675687", "0789901250", "2323249a@619f78c5.com", ""], ["fb011bf6", "0750579472", "7152dcf1@668f96db.com", ""], ["a623d8fd", "0733289400", "482d64cd@8a4e2926.com", ""], ["6d961e75", "", "6fe4f5e1@e9239485.com", ""], ["4f39910e", "0746456899", "e85969c9@cca744af.com", ""], ["81a834a8", "0731458518", "8ffce3c8@8e7ae609.com", ""], ["70472837", "0775526068", "5dbfd52d@0d829cb6.com", ""], ["2d882041", "0708561610", "e5853dfb@b2187974.com", ""], ["deaf54bc", "", "a9dc3872@2506f15e.com", ""], ["a7226533", "0760964433", "2490dcc2@de364340.com", ""], ["36377c3b", "0759911971", "93465967@b06cdaa6.com", ""], ["31724c44", "0757697274", "b04d8ef1@544a5467.com", ""], ["51806270", "0742044114", "417f3dca@cab99b90.com", ""], ["e9b2295d", "", "d9e360f5@4d87b309.com", ""], ["0dee89bb", "0712052963", "4700d9dc@8b9161ce.com", ""], ["8a4b9e30", "0755847836", "b3d261a8@60e7e6d9.com", ""], ["f8f7bc44", "", "ffd58a0e@e40c3e44.com", ""], ["c5d5d713", "0790256137", "35f89e39@5893f2c1.com", ""], ["90f0cbd9", "0737155772", "6d8e7f05@40038b63.com", ""], ["eef1d1a9", "0742179171", "065c13a8@634d0446.com", ""], ["55faa2a4", "0748611843", "22382d4c@d11eb075.com", ""], ["f7913ace", "", "40be2255@400d8d4e.com", ""], ["0d47fd15", "0797162254", "06ec4142@00a16805.com", ""], ["906a5fb1", "0703201953", "4d249ff5@55ffe749.com", ""], ["4b5010c3", "0741317092", "25d81157@6027b9e1.com", ""], ["f79b697f", "0773396997", "2e116cf9@9a6bfcba.com", ""], ["c6d2d0f0", "", "a83108a9@40159f26.com", ""], ["da24f341", "0775328597", "4e7592d1@70f4d9c1.com", ""], ["b20edca2", "0789450137", "d326baf5@1baafa6e.com", ""], ["3e11a76a", "0776368949", "15a351bc@e4b229ff.com", ""], ["3e2fbdc3", "0717744150", "4ebbd18a@de822931.com", ""], ["7e6c9549", "0750887467", "2c5034ed@23402dc1.com", ""], ["9536e16d", "0713928189", "e6cd0a97@d20db0c5.com", ""], ["8358cf78", "0735864901", "4ea98e6a@e1052078.com", ""], ["d12cef0a", "0730971666", "a7f6061d@b6f2822f.com", ""], ["cc2ee54b", "0778562618", "61917c3e@032bcdb5.com", ""], ["21ef6091", "0763511033", "166d075e@522d8051.com", ""], ["f6b7a396", "0772929913", "13ae6bf9@c44fbe8b.com", ""], ["042555cf", "", "b11161b5@143fc047.com", ""], ["9739c45e", "0792544143", "9a57d50c@7545618b.com", ""], ["aad5cccf", "0781328164", "59e7123e@488f0065.com", ""], ["b886493a", "0785224849", "d6499e6f@0688b326.com", ""], ["2c99ee07", "0748501224", "1e582961@799a7165.com", ""], ["72545b19", "0715248877", "637c5be0@c9d62b4b.com", ""], ["6f038b04", "0717198089", "fa325ae7@624b634c.com", ""], ["4f8e0b2a", "0764277265", "cb4a5eab@35f7346a.com", ""], ["03fdeddd", "0721789994", "c0476d68@4d5cd7bd.com", ""], ["89d74e2d", "0760756190", "d6594d52@5dee56e4.com", ""], ["c24acb5f", "0781827089", "3a1ea265@8bd8e1cc.com", ""], ["d1bf9230", "0722180686", "9e582b1e@e8351d46.com", ""], ["9dd2e88d", "0711860229", "56658ca4@132ac241.com", ""], ["a07824cb", "0741457266", "76f4cfea@f25c3adc.com", ""], ["f3897859", "0710503175", "8f23c250@4540617e.com", ""], ["c09a14dd", "", "e9c464ac@f76cfe06.com", ""], ["c9a758e4", "0790149404", "b5fbb29e@2ef70cf1.com", ""], ["dd56f5d4", "0709704673", "8b24db91@663c1804.com", ""], ["1ccf7b2e", "0728115684", "d7610e72@e4a8b643.com", ""], ["32351dff", "0748120068", "3939d0ab@8fdf1e7d.com", ""], ["8c4053c5", "0714496140", "420781c7@8ccbf30e.com", ""], ["ce266357", "0784144894", "de2e719c@22b747ed.com", ""], ["3257e91e", "0783497860", "09f03ee1@a98f49a0.com", ""], ["0180d6f1", "0743825253", "ec4bf604@a0b51abc.com", ""], ["5360eee3", "0719368035", "bb270d1d@984f9d39.com", ""], ["d40e19a5", "", "5ccc6f37@d6870955.com", ""], ["a429a2e0", "", "9372812a@b1676aa3.com", ""], ["45dfc0c2", "0709417666", "fed1b00e@bf665131.com", ""], ["a173558c", "0758991067", "3f706fd9@301776a7.com", ""], ["ca87b3af", "0781867475", "98155b79@adcd5507.com", ""], ["036da0ce", "0778243834", "66467ae3@686faa6d.com", ""], ["c5ddfc00", "0750665015", "4e0a5924@9d77bef0.com", ""], ["40b39118", "0765997451", "7bdfe708@a3cee04d.com", ""], ["a5de4669", "0761205980", "bfac2446@e1515b33.com", ""], ["a62448b2", "0739220943", "3da472cc@73d9e74b.com", ""], ["3b04bf59", "0711824354", "b6ac5778@42772967.com", ""], ["ab56dd06", "0791996063", "3949b2d8@b08c4c5f.com", ""], ["9820b796", "0724164512", "ecad52ea@81041046.com", ""], ["894e5af9", "0706784563", "4b2ee10d@821e0a2e.com", ""], ["dd06ec54", "0782812141", "a2e91578@875cac30.com", ""], ["802f32a7", "0749216707", "8e7171df@898e4f66.com", ""], ["ff30e79e", "", "e09f32d2@e8bed799.com", ""], ["7070b09d", "0707078844", "bfb66733@308be7e8.com", ""], ["fde6085d", "0712529236", "c4750b1e@05266810.com", ""], ["87f4aafd", "0739190648", "1cb92876@1cf2f6df.com", ""], ["94a88efa", "0775446056", "f0ce2f87@410508db.com", ""], ["20f56909", "0740230843", "55997ce7@82f437b1.com", ""], ["1ee26c6c", "0713640395", "6357a25b@fb48f794.com", ""], ["ee1ba135", "0789826448", "097f6423@892f4d97.com", ""], ["7fa6baf5", "0752582669", "6e49ac6d@7012d5a1.com", ""], ["35fc8c1d", "0789186620", "0abe0f3f@2435673a.com", ""], ["0e7434e8", "0791384205", "96c88dc9@8e26852c.com", ""], ["6a899564", "", "65902813@1686d624.com", ""], ["90d1935e", "0756326458", "8ddc4a04@a5572b9d.com", ""], ["f8c69667", "", "c827107a@93f105af.com", ""], ["b2c511ea", "0749010920", "24bb2d69@f4287f85.com", ""], ["6ecc592e", "0788769694", "4c132913@931ce84d.com", ""], ["6722b9db", "0794262991", "52bed401@35003685.com", ""], ["6874d990", "0748452679", "b5dca8b6@97c1b768.com", ""], ["72740c63", "0721748699", "9e10c395@a98c8e1c.com", ""], ["ee0d78e0", "0795469696", "56d8a295@d6ef7c7e.com", ""], ["d5bcb2aa", "0741284724", "34c858aa@5f49689d.com", ""], ["5909a519", "0744679678", "88409af7@e19f4243.com", ""], ["f265acfa", "0792029151", "e34d1172@0e9bba98.com", ""], ["94a5602e", "0709917575", "0464515e@552c3013.com", ""], ["d0d483ad", "0764326930", "86b8c70f@a6382da0.com", ""], ["b9155373", "0725007352", "e3223601@f5a902c8.com", ""], ["600e630d", "", "f4e4d66b@bf8aa87c.com", ""], ["fe12bd24", "0772663541", "8b8b1297@1e02a9bf.com", ""], ["fc11b86a", "", "65f3c1ac@375002e4.com", ""], ["110d8fa4", "0775226701", "3d955f6b@ce7da885.com", ""], ["d3105be1", "0779779352", "ed2be0eb@ccc7485e.com", ""], ["fda61a0b", "0720274167", "6becb3e3@4deb6681.com", ""], ["17f8be17", "0799234608", "73f14970@cb957e59.com", ""], ["e9516a1d", "0760461815", "8ad30006@8df0fd1a.com", ""], ["60177106", "0760558146", "dc029d5c@b6ef2fd2.com", ""], ["3450866c", "0711798090", "cda58109@c2e25542.com", ""], ["06e26393", "0735961481", "305bcf19@6c3ec630.com", ""], ["708af001", "0728438353", "048b067d@80d72f50.com", ""], ["c40ec0a3", "0726279359", "a10d333e@9a89cb7c.com", ""], ["2a2eb847", "0741461630", "d3daf952@7a3408e4.com", ""], ["d01a1813", "0704645200", "f226e03a@f67c7d10.com", ""], ["819cb8a9", "0798337915", "cf70d59b@d1a42e77.com", ""], ["6fa32598", "0701415106", "4a225f04@2534e6a3.com", ""], ["4e441149", "", "456f82ab@ca0b04a5.com", ""], ["f7ff1a4a", "0701515624", "5918544a@4bb3704b.com", ""], ["5738b682", "0724843216", "04c74b30@ed510bac.com", ""], ["f10e2d6a", "0730727806", "415d4081@b1a88a68.com", ""], ["749e83bd", "", "15e0671b@76428121.com", ""], ["3d65b85b", "", "d1eb4053@c363e69a.com", ""], ["a0792a15", "0729947768", "c24f344e@41f87b82.com", ""], ["fb01d572", "0792621720", "9c750d18@7fb1f5ce.com", ""], ["e67acc97", "0789099675", "bdfbe8ff@e2ca97d6.com", ""], ["a9e6cd4b", "0708488791", "1826128f@98d76c9c.com", ""], ["819f95a3", "0788991943", "cf458e5d@44f3c946.com", ""], ["20204d4c", "0714720322", "1efe1d68@5dddd6aa.com", ""], ["a1ea35bb", "0749468567", "480f2ab1@72a338ed.com", ""], ["92ffc165", "0780038157", "4dd11686@daa7a1e3.com", ""], ["4ba3f501", "", "71b12e98@a76b654f.com", ""], ["d7f5285b", "0736359794", "fc0e04a5@dde7291b.com", ""], ["77bfe67f", "0742014858", "b3c8790c@cacf5548.com", ""], ["dbf35774", "0726444745", "2243d1d2@138dfd54.com", ""], ["e9f91a9f", "0760618445", "db7cbba8@4616651c.com", ""], ["04c6be09", "", "9adf3814@5e01afe2.com", ""], ["b57e7ee3", "0722578719", "1736e501@964d3424.com", ""], ["d8114397", "", "6bc5e72a@bc4d7e37.com", ""], ["7ab21681", "", "aa33cd18@d6af8a2b.com", ""], ["290e81e9", "", "4d6f5933@bb9b4aa8.com", ""], ["5a1f2ac9", "0778832081", "53b60c08@8973dcfb.com", ""], ["f8775a07", "0743005567", "3c53f38a@c0918592.com", ""], ["30c7cdb1", "0779422190", "a809f36f@1b3dc9e3.com", ""], ["1c669546", "0793826953", "0b2728a3@f2f4eee2.com", ""], ["229beef7", "", "97621f15@237e6070.com", ""], ["ab8c48d5", "", "8545951c@c7523008.com", ""], ["e4294ab1", "", "6d8bdfd5@e9c1c910.com", ""], ["c9c8fbe2", "0729515527", "bcd2115d@e4500586.com", ""], ["0b6463d3", "0725941332", "e768eb3b@fbd48ad0.com", ""], ["15bbab63", "", "a8fd2422@b236a910.com", ""], ["c181cff0", "0717305018", "fb4e37ed@b611bc36.com", ""], ["03f712cd", "", "a05576b0@e16b8368.com", ""], ["31105e59", "0790432797", "6e4a7e2a@d6902424.com", ""], ["5c868ce0", "0738424344", "36348d0f@342e6bed.com", ""], ["d47bd754", "0793838363", "dd9d97d7@54a56407.com", ""], ["660908ec", "0707537040", "ae9b6681@a9382430.com", ""], ["2c6290a0", "0759084568", "b011def2@e1ac3f5a.com", ""], ["a989f05a", "", "25e59710@79b7f417.com", ""], ["f3c16d3b", "", "ded9819d@a99d7dc3.com", ""], ["1adf7818", "0751467205", "b96aa90a@9791c269.com", ""], ["409cc67e", "0777500182", "5317c827@a2db4c45.com", ""], ["aabd103f", "", "e5d907aa@bcc71e10.com", ""], ["d23bf430", "0777169222", "85e8641e@cc5f3d8b.com", ""], ["69a2879f", "0743889293", "4b5f4141@04205775.com", ""], ["8a3ec274", "0756861440", "ee663794@82c1f4fe.com", ""], ["b4a3cd48", "", "fc399765@64ca060b.com", ""], ["e0ccaed3", "", "6388033c@824e8b90.com", ""], ["c3eee2b9", "", "c0162073@2c2fd2b2.com", ""], ["a73c64c4", "", "f688a2e5@6e30672c.com", ""], ["2d852e73", "0772174434", "7a42d0d9@72486cd3.com", ""], ["a12254f5", "0769384878", "caf08e44@d46ed98b.com", ""], ["f653b74f", "0763997467", "388c7213@1126f95d.com", ""], ["fc93ee15", "0774848627", "beabdaca@2d84271e.com", ""], ["73c04bfd", "0771623831", "8fe53552@31ce4032.com", ""], ["74a5f23f", "0789722672", "82930bac@7dc3e501.com", ""], ["796d3e3d", "0762835594", "1181180a@ec66e2f0.com", ""], ["1bc14fc5", "0704902009", "ed326523@23e42c30.com", ""], ["5c08dcb0", "0765115244", "dd5945cf@f297b86b.com", ""], ["566bf582", "0723354400", "ea731083@ef83ec7c.com", ""], ["f25208d4", "0789906022", "457e0601@4eb796dd.com", ""], ["114eeeb3", "0758751442", "14cfa76b@aee76d12.com", ""], ["2669d4a2", "", "5ea1e2ac@cf638cf0.com", ""], ["6202f1c1", "", "d93e09a6@d3898452.com", ""], ["5126ade5", "", "96601158@cfeca444.com", ""], ["a0cef71f", "", "e9366869@532d79f8.com", ""], ["41bda0e9", "", "c4206b97@966c822f.com", ""], ["a75dc6cc", "", "12906204@892175ee.com", ""], ["79636bcc", "", "c45c9aae@a303f7d9.com", ""], ["e40fb55e", "", "72fe75ec@c1ddfd45.com", ""], ["e0ddb510", "", "ea6c8b14@193b1240.com", ""], ["c7532313", "", "a9361a51@f562d91e.com", ""], ["0158d9f3", "", "1de7b7e6@876beb92.com", ""], ["b2394690", "", "fe1b9570@3cffd682.com", ""], ["79051273", "", "fbb85c72@4532b2d6.com", ""], ["1ea2aeae", "", "6e8d529a@fa5af449.com", ""], ["2c637d3a", "", "b633e7ca@b2e822e5.com", ""], ["ba3b59cc", "", "10e596a4@e2654efc.com", ""], ["631c774b", "", "03b0156e@5f3383bd.com", ""], ["6e4d8e1d", "", "b7026e82@015d71cc.com", ""], ["3d430d86", "", "6af17483@bf92c6e9.com", ""], ["ea26cfde", "", "aeee3cf2@e06f3387.com", ""], ["16596f6f", "", "d1445706@a3282036.com", ""], ["9bc58d49", "", "", ""], ["cc907372", "", "a923bc16@c3febc01.com", ""], ["605dd0be", "", "49daa341@8ee4c07e.com", ""], ["1b1114f0", "", "ec5a8a0b@489a7dd5.com", ""], ["28b0c962", "", "", ""], ["63370e89", "", "44d07879@3c414118.com", ""], ["f3f1db57", "", "", ""], ["ac49009b", "", "e5cd7740@ea261a3b.com", ""], ["9e46dcc0", "", "83a47ee1@a58545dc.com", ""], ["2e95367c", "", "8bee6be7@8906c53f.com", ""], ["ea22c7bb", "", "53b4805b@af24dcf7.com", ""], ["c90f526b", "", "d790585d@8d10cf2a.com", ""], ["7887a62b", "", "7120351b@ff19865b.com", ""], ["64cb3f9d", "", "", ""], ["9e591fa7", "", "c7730953@a714f3a7.com", ""], ["1f16cd07", "", "67bebc78@40ed8809.com", ""], ["287ec13a", "", "9e5e71cb@2bb1c891.com", ""], ["d164c461", "", "1cd6e067@14c6ada1.com", ""], ["36379fd3", "", "72a5809d@9b00b1bb.com", ""], ["9470c307", "", "2816f5ec@451ff875.com", ""], ["1655c648", "", "f06ec4a5@f5ab6d4c.com", ""], ["879040ac", "", "62e235cb@ec8a0c43.com", ""], ["50232cdf", "", "025cd190@2477e789.com", ""], ["5a35f64f", "", "", ""], ["777de661", "", "38640b10@41514086.com", ""], ["705fb8cf", "", "7284860f@b222b1f8.com", ""], ["fcb30c1b", "", "361fde21@1f411552.com", ""], ["079e7250", "", "6bbcefcd@2aeb37af.com", ""], ["4e47023c", "", "d23b6b4d@b0b56474.com", ""], ["1bce884e", "", "1dfab7b8@68a3a0ef.com", ""], ["7088e56a", "", "6570590b@e2a30ae0.com", ""], ["65ce20d2", "", "86106467@58070f61.com", ""], ["68d599aa", "", "e279dc2a@58d99123.com", ""], ["0142c624", "", "9abe7329@cb4a4022.com", ""], ["9e8bcae3", "", "840f16ca@1ed99fc8.com", ""], ["4b854254", "", "fbf12ee9@6499201a.com", ""], ["615d5c95", "", "abce7c9f@ade377af.com", ""], ["d389a4b2", "", "a78f0fc2@a6f959cd.com", ""], ["38be0158", "", "bfa96d44@f363eb7c.com", ""], ["f9e06f7c", "", "542bd29b@97746a9e.com", ""], ["832811a7", "", "9bf9010e@01957fac.com", ""], ["359a78e3", "", "3248a6d3@763ddb24.com", ""], ["27f9817c", "", "07d94000@2aca2301.com", ""], ["013a3bd4", "", "2224822e@fade404f.com", ""], ["a0ef9d39", "", "", ""], ["b024d6ee", "", "0daa6bb1@2ef82615.com", ""], ["a381acc3", "", "bad81f63@dc7acb79.com", ""], ["7610e7b9", "", "59829816@d8a49cb9.com", ""], ["4460acd9", "", "acf4b236@dd8a5e68.com", ""], ["5193bc70", "", "f5620364@f763b237.com", ""], ["9d134fef", "", "df0234b6@ae3b90cf.com", ""], ["8d8c4586", "", "f92d2add@0f93928c.com", ""], ["2d12e299", "", "99162fbe@59bd4569.com", ""], ["bcc4641b", "", "afff34a5@f2e5d247.com", ""], ["5c2243c3", "", "3ef0d599@f34a9ed2.com", ""], ["427913d9", "", "086fe7e8@925e9a12.com", ""], ["3a60c0ec", "", "66c4dfbc@cee34594.com", ""], ["ffca31e8", "", "0a4a245c@68e91e5e.com", ""], ["7df057ab", "", "335cabf9@a17a61b3.com", ""], ["71307e65", "", "c1de44cd@a1add235.com", ""], ["17bf2c39", "", "601cc244@f0efd775.com", ""], ["a8b9e2e1", "", "de175716@d0cfb7a0.com", ""], ["a294ae4d", "", "", ""], ["37859daf", "", "376f0f2b@369a7bcf.com", ""], ["01e1685f", "", "6fed663d@3672e214.com", ""], ["0a32e0c6", "", "0b0791c3@2c33f23e.com", ""], ["5b8ff540", "", "29599ad0@548964c2.com", ""], ["eec1094b", "", "dfa57b8f@80b6e55e.com", ""], ["acf49b4d", "", "4c20a1c0@c6d0e041.com", ""], ["63867c55", "", "", ""], ["873984c8", "", "1cf228b4@b15a1b5f.com", ""], ["ecde582c", "", "cc419b7b@60107818.com", ""], ["039d5e8f", "", "5da65ece@1bb69031.com", ""], ["21f04ac9", "", "8ee84896@25275182.com", ""], ["735349c7", "", "117051ac@3a4fd4d7.com", ""], ["273761c9", "", "0184bda8@198fe950.com", ""], ["200dff3c", "", "cb311031@7c6cc49c.com", ""], ["80a68462", "", "5f3c6275@44263f25.com", ""], ["53ace4f9", "", "733a3c0e@c7fd4969.com", ""], ["484e9625", "", "bfe97c09@d2ef03d6.com", ""], ["b798a822", "", "cd531d09@aa763932.com", ""], ["04cecc11", "", "e173c817@3977a28a.com", ""], ["2a910e01", "", "5e200dad@5cf70a26.com", ""], ["540b1452", "", "c9462faa@cb7652ed.com", ""], ["80261f65", "", "fea8b5bc@e5018e45.com", ""], ["9e4fa7c2", "", "95f3f9c9@f265aefc.com", ""], ["f0227223", "", "702f9660@2006f10b.com", ""], ["500bb986", "", "40a22a5c@ed4055c3.com", ""], ["34c14a29", "", "3d2633f6@206a53a0.com", ""], ["66a868a5", "", "7a2b1e68@9cf601fb.com", ""], ["af8d6986", "", "", ""], ["1fbefa32", "", "e948b9f8@c0fe216c.com", ""], ["ee799cb5", "", "01013bb3@229547a9.com", ""], ["b2ce8d2e", "", "fe3750e1@ee50e669.com", ""], ["ae7c0d5b", "0727494158", "68aad918@674f032f.com", ""], ["1c55c64e", "", "efc9a2bb@1fec90ba.com", ""], ["2eef74a3", "", "238d16ec@14658003.com", ""], ["5ba48a58", "", "c6d52520@65f96971.com", ""], ["e650ca0a", "", "76be4eba@a74dba94.com", ""], ["f977adbb", "0789591713", "68b0f662@c01e961d.com", ""], ["ac083f57", "0718005506", "6443f242@7ed34530.com", ""], ["0c7080de", "0791608592", "4444abf7@63b54852.com", ""], ["599039e0", "0717263596", "34898b46@b248d961.com", ""], ["33656705", "0788380435", "0d6c86a9@0d0b30a4.com", ""], ["f0d6f309", "0766220442", "22d01833@9bba21ab.com", ""], ["7c27f7a0", "0740179768", "69d0ce4f@829116df.com", ""], ["cba407c8", "0745165304", "4e94762c@6033decd.com", ""], ["3a9c0bcd", "0738794804", "ea1ca3ef@bd6258aa.com", ""], ["006b2bde", "0759645406", "822277ef@e36775c4.com", ""], ["fe832f26", "", "", ""], ["03c43ae1", "0776208246", "8e64a53e@d0aefbfb.com", ""], ["4ae6a913", "0798677379", "4affe084@f4ee6d7c.com", ""], ["5d3d3ca1", "0755000779", "0ea61d50@7ffd96e5.com", ""], ["dc58f75f", "0717795899", "119adc66@edbc9bac.com", ""], ["593f7e23", "", "d3161d49@fe2ad83f.com", ""], ["e5075ec4", "0760361665", "2f410a97@1b02d6a2.com", ""], ["a0d86ad4", "0765965949", "0fb55b69@cd1c2b77.com", ""], ["09e6e8a1", "0790479077", "cc542c67@9cb69e36.com", ""], ["77bb6d11", "0720961023", "60ff31ae@d9349e56.com", ""], ["e2f4aed5", "0735608325", "663b0400@671dff5d.com", ""], ["149211a9", "0706545912", "8481da49@233dfe8e.com", ""], ["59a53ba0", "", "3401405f@e1c370ad.com", ""], ["60a4c106", "0746099226", "7003f4bf@6a7770b2.com", ""], ["4483788d", "0728712830", "b30ee032@1694bb85.com", ""], ["ee8a3129", "0712964023", "34ee5c80@c82fb116.com", ""], ["c8bdde49", "", "3ac6c6dd@13bc3322.com", ""], ["65a477be", "0705359300", "a67e1d49@966dedb0.com", ""], ["d1a51add", "", "", ""], ["467392b8", "0765663197", "f878eeb4@7334e9fd.com", ""], ["61b4a1d6", "", "9d7e523e@e334c7d2.com", ""], ["16bbecf4", "0794827711", "39dff936@596fb073.com", ""], ["1cd25515", "0786915799", "d1c9de87@fb823959.com", ""], ["9d653b08", "0783707353", "75f1c3fa@dca1ac44.com", ""], ["cb93adc9", "0757349238", "7c3f098b@cf9bb4fe.com", ""], ["5f879d9e", "0764695115", "0a710345@b99b17e3.com", ""], ["8b840055", "", "", ""], ["d436dacc", "0796095598", "ea120899@3c8f6ea0.com", ""], ["1d0faf93", "0744424802", "3cf588a8@44544be8.com", ""], ["4ee8b8f7", "0764292501", "", ""]] \ No newline at end of file From 9d175bdfd60cda0335c0229876e46f72e7101249 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 27 Sep 2012 11:50:38 +0200 Subject: [PATCH 17/43] [FIX] postgres 9.2 added data at the end of 23502's error message, only use prefix matches not fullstring bzr revid: xmo@openerp.com-20120927095038-m2vm663nqj1s1pne --- openerp/osv/orm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index 81063deb180..641d21d6327 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -5376,7 +5376,7 @@ class ImportWarning(Warning): def convert_pgerror_23502(model, fields, info, e): m = re.match(r'^null value in column "(?P\w+)" violates ' - r'not-null constraint\n$', + r'not-null constraint\n', str(e)) if not m or m.group('field') not in fields: return {'message': unicode(e)} From f39730e918bc85c770dfa6edd52394351d54d006 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 27 Sep 2012 12:34:52 +0200 Subject: [PATCH 18/43] [FIX] make conversion errors for floats and integers more predictable Python turns out to have changed the wording of the messages between 2.6 and 2.7 also, these messages need to be translatable *and* use the human-readable field name bzr revid: xmo@openerp.com-20120927103452-cvgv3e8dsvcvnics --- openerp/addons/base/ir/ir_fields.py | 14 ++++++++++++-- openerp/tests/addons/test_impex/tests/test_load.py | 4 ++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 621dd93d469..ba6681bee15 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -100,10 +100,20 @@ class ir_fields_converter(orm.Model): return True def _str_to_integer(self, cr, uid, model, column, value, context=None): - return int(value) + try: + return int(value) + except ValueError: + raise ValueError( + _(u"'%s' does not seem to be an integer for field '%%(field)s'") + % value) def _str_to_float(self, cr, uid, model, column, value, context=None): - return float(value) + try: + return float(value) + except ValueError: + raise ValueError( + _(u"'%s' does not seem to be a number for field '%%(field)s'") + % value) def _str_to_char(self, cr, uid, model, column, value, context=None): return value diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 72442963af5..032dd65ef07 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -275,7 +275,7 @@ class test_integer_field(ImporterCase): 'rows': {'from': 0, 'to': 0}, 'record': 0, 'field': 'value', - 'message': u"invalid literal for int() with base 10: 'zorglub'", + 'message': u"'zorglub' does not seem to be an integer for field 'unknown'", }]) class test_float_field(ImporterCase): @@ -344,7 +344,7 @@ class test_float_field(ImporterCase): 'rows': {'from': 0, 'to': 0}, 'record': 0, 'field': 'value', - 'message': u"invalid literal for float(): foobar", + 'message': u"'foobar' does not seem to be a number for field 'unknown'", }]) class test_string_field(ImporterCase): From 87af7a63654b279d79a8b4eb3c5fb675ef7a8708 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 27 Sep 2012 16:43:41 +0200 Subject: [PATCH 19/43] [FIX] replace warnings capture by explicitly returning a list of warnings from converters makes converter code noisier, but ultimately simpler without having access to dynamically scoped variables bzr revid: xmo@openerp.com-20120927144341-j1dx3e0epf3i5ja6 --- openerp/addons/base/ir/ir_fields.py | 82 ++++++++++--------- openerp/osv/orm.py | 10 +-- .../addons/test_impex/tests/test_load.py | 2 +- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index ba6681bee15..7a4c34c7f30 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -2,7 +2,6 @@ import functools import operator import itertools -import warnings from openerp.osv import orm, fields from openerp.tools.translate import _ @@ -38,9 +37,9 @@ class ir_fields_converter(orm.Model): By default, tries to get a method on itself with a name matching the pattern ``_$fromtype_$column._type`` and returns it. - Converter callables can either return a value to their caller or raise - ``ValueError``, which will be interpreted as a validation & conversion - failure. + Converter callables can either return a value and a list of warnings + to their caller or raise ``ValueError``, which will be interpreted as a + validation & conversion failure. ValueError can have either one or two parameters. The first parameter is mandatory, **must** be a unicode string and will be used as the @@ -53,10 +52,10 @@ class ir_fields_converter(orm.Model): client. If a converter can perform its function but has to make assumptions - about the data, it can send a warning to the user through signalling - an :class:`~openerp.osv.orm.ImportWarning` (via ``warnings.warn``). The - handling of a warning at the upper levels is the same as - ``ValueError`` above. + about the data, it can send a warning to the user through adding an + instance of :class:`~openerp.osv.orm.ImportWarning` to the second value + it returns. The handling of a warning at the upper levels is the same + as ``ValueError`` above. :param cr: openerp cursor :param uid: ID of user calling the converter @@ -84,7 +83,7 @@ class ir_fields_converter(orm.Model): self._get_translations(cr, uid, ['code'], u"true", context=context), self._get_translations(cr, uid, ['code'], u"yes", context=context), )) - if value.lower() in trues: return True + if value.lower() in trues: return True, [] # potentially broken casefolding? What about locales? falses = set(word.lower() for word in itertools.chain( @@ -92,16 +91,15 @@ class ir_fields_converter(orm.Model): self._get_translations(cr, uid, ['code'], u"false", context=context), self._get_translations(cr, uid, ['code'], u"no", context=context), )) - if value.lower() in falses: return False + if value.lower() in falses: return False, [] - warnings.warn(orm.ImportWarning( + return True, [orm.ImportWarning( _(u"Unknown value '%s' for boolean field '%%(field)s', assuming '%s'") - % (value, yes))) - return True + % (value, yes))] def _str_to_integer(self, cr, uid, model, column, value, context=None): try: - return int(value) + return int(value), [] except ValueError: raise ValueError( _(u"'%s' does not seem to be an integer for field '%%(field)s'") @@ -109,20 +107,20 @@ class ir_fields_converter(orm.Model): def _str_to_float(self, cr, uid, model, column, value, context=None): try: - return float(value) + return float(value), [] except ValueError: raise ValueError( _(u"'%s' does not seem to be a number for field '%%(field)s'") % value) def _str_to_char(self, cr, uid, model, column, value, context=None): - return value + return value, [] def _str_to_text(self, cr, uid, model, column, value, context=None): - return value + return value, [] def _str_to_binary(self, cr, uid, model, column, value, context=None): - return value + return value, [] def _get_translations(self, cr, uid, types, src, context): types = tuple(types) @@ -151,7 +149,7 @@ class ir_fields_converter(orm.Model): cr, uid, ('selection', 'model', 'code'), label, context=context) labels.append(label) if value == unicode(item) or value in labels: - return item + return item, [] raise ValueError( _(u"Value '%s' not found in selection field '%%(field)s'") % ( value), { @@ -173,11 +171,14 @@ class ir_fields_converter(orm.Model): id :param value: value of the reference to match to an actual record :param context: OpenERP request context - :return: a pair of the matched database identifier (if any) and the - translated user-readable name for the field - :rtype: (ID|None, unicode) + :return: a pair of the matched database identifier (if any), the + translated user-readable name for the field and the list of + warnings + :rtype: (ID|None, unicode, list) """ + if context is None: context = {} id = None + warnings = [] RelatedModel = self.pool[column._obj] if subfield == '.id': field_type = _(u"database id") @@ -191,7 +192,7 @@ class ir_fields_converter(orm.Model): if '.' in value: module, xid = value.split('.', 1) else: - module, xid = '', value + module, xid = context.get('_import_current_module', ''), value ModelData = self.pool['ir.model.data'] try: md_id = ModelData._get_id(cr, uid, module, xid) @@ -206,7 +207,7 @@ class ir_fields_converter(orm.Model): cr, uid, name=value, operator='=', context=context) if ids: if len(ids) > 1: - warnings.warn(orm.ImportWarning( + warnings.append(orm.ImportWarning( _(u"Found multiple matches for field '%%(field)s' (%d matches)") % (len(ids)))) id, _name = ids[0] @@ -227,7 +228,7 @@ class ir_fields_converter(orm.Model): 'help': _(u"See all possible values") } }) - return id, field_type + return id, field_type, warnings def _referencing_subfield(self, record): """ Checks the record for the subfields allowing referencing (an @@ -236,8 +237,8 @@ class ir_fields_converter(orm.Model): returns the name of the correct subfield. :param record: - :return: the record subfield to use for referencing - :rtype: str + :return: the record subfield to use for referencing and a list of warnings + :rtype: str, list """ # Can import by name_get, external id or database id fieldset = set(record.iterkeys()) @@ -250,39 +251,42 @@ class ir_fields_converter(orm.Model): # only one field left possible, unpack [subfield] = fieldset - return subfield + return subfield, [] def _str_to_many2one(self, cr, uid, model, column, values, context=None): # Should only be one record, unpack [record] = values - subfield = self._referencing_subfield(record) + subfield, w1 = self._referencing_subfield(record) reference = record[subfield] - id, subfield_type = self.db_id_for( + id, subfield_type, w2 = self.db_id_for( cr, uid, model, column, subfield, reference, context=context) - return id + return id, w1 + w2 def _str_to_many2many(self, cr, uid, model, column, value, context=None): [record] = value - subfield = self._referencing_subfield(record) + subfield, warnings = self._referencing_subfield(record) ids = [] for reference in record[subfield].split(','): - id, subfield_type = self.db_id_for( + id, subfield_type, ws = self.db_id_for( cr, uid, model, column, subfield, reference, context=context) ids.append(id) - return [(6, 0, ids)] + warnings.extend(ws) + return [(6, 0, ids)], warnings def _str_to_one2many(self, cr, uid, model, column, records, context=None): commands = [] + warnings = [] if len(records) == 1 and exclude_ref_fields(records[0]) == {}: # only one row with only ref field, field=ref1,ref2,ref3 as in # m2o/m2m record = records[0] - subfield = self._referencing_subfield(record) + subfield, ws = self._referencing_subfield(record) + warnings.extend(ws) # transform [{subfield:ref1,ref2,ref3}] into # [{subfield:ref1},{subfield:ref2},{subfield:ref3}] records = ({subfield:item} for item in record[subfield].split(',')) @@ -292,10 +296,12 @@ class ir_fields_converter(orm.Model): refs = only_ref_fields(record) # there are ref fields in the record if refs: - subfield = self._referencing_subfield(refs) + subfield, w1 = self._referencing_subfield(refs) + warnings.extend(w1) reference = record[subfield] - id, subfield_type = self.db_id_for( + id, subfield_type, w2 = self.db_id_for( cr, uid, model, column, subfield, reference, context=context) + warnings.extend(w2) writable = exclude_ref_fields(record) if id: @@ -304,4 +310,4 @@ class ir_fields_converter(orm.Model): else: commands.append(CREATE(writable)) - return commands + return commands, warnings diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index 641d21d6327..9bd04dd56a8 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1670,15 +1670,9 @@ class BaseModel(object): message_base = dict( extras, record=stream.index, field=field_names[field]) try: - with warnings.catch_warnings(record=True) as ws: - converted[field] = converters[field](strvalue) + converted[field], ws = converters[field](strvalue) - for warning in ws: - # bubble non-import warnings upward - if warning.category != ImportWarning: - warnings.warn(warning.message, warning.category) - continue - w = warning.message + for w in ws: if isinstance(w, basestring): # wrap warning string in an ImportWarning for # uniform handling diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 032dd65ef07..a5c3dcc5bbf 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -186,7 +186,7 @@ class test_boolean_field(ImporterCase): message(u"Unknown value '%s' for boolean field 'unknown', assuming 'yes'" % v[0], type='warning', from_=i, to_=i, record=i) for i, v in enumerate(trues) - if v[0] != 'true' if v[0] != 'yes' if v[0] != '1' + if v[0] not in ('true', 'yes', '1') ]) self.assertEqual( [True] * 10, From def7e61901dafc41cb0b404b1e74275b00deaa1b Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 27 Sep 2012 17:42:44 +0200 Subject: [PATCH 20/43] [IMP] reimplement BaseModel.import_data on top of BaseModel.load adapt tests to assert the corrected behavior of load instead of import_data's broken behavior bzr revid: xmo@openerp.com-20120927154244-e56ygz2yytullg2l --- openerp/osv/orm.py | 196 +++--------------- .../addons/test_impex/tests/test_import.py | 175 +++++++--------- .../addons/test_impex/tests/test_load.py | 4 +- 3 files changed, 98 insertions(+), 277 deletions(-) diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index 9bd04dd56a8..934b6eaf2e4 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1254,189 +1254,43 @@ class BaseModel(object): :returns: 4-tuple in the form (return_code, errored_resource, error_message, unused) :rtype: (int, dict or 0, str or 0, str or 0) """ - if not context: - context = {} + context = dict(context) if context is not None else {} + context['_import_current_module'] = current_module + fields = map(fix_import_export_id_paths, fields) ir_model_data_obj = self.pool.get('ir.model.data') - # mode: id (XML id) or .id (database id) or False for name_get - def _get_id(model_name, id, current_module=False, mode='id'): - if mode=='.id': - id = int(id) - obj_model = self.pool.get(model_name) - ids = obj_model.search(cr, uid, [('id', '=', int(id))]) - if not len(ids): - raise Exception(_("Database ID doesn't exist: %s : %s") %(model_name, id)) - elif mode=='id': - if '.' in id: - module, xml_id = id.rsplit('.', 1) - else: - module, xml_id = current_module, id - record_id = ir_model_data_obj._get_id(cr, uid, module, xml_id) - ir_model_data = ir_model_data_obj.read(cr, uid, [record_id], ['res_id']) - if not ir_model_data: - raise ValueError('No references to %s.%s' % (module, xml_id)) - id = ir_model_data[0]['res_id'] - else: - obj_model = self.pool.get(model_name) - ids = obj_model.name_search(cr, uid, id, operator='=', context=context) - if not ids: - raise ValueError('No record found for %s' % (id,)) - id = ids[0][0] - return id + def log(m): + if m['type'] == 'error': + raise Exception(m['message']) - # IN: - # datas: a list of records, each record is defined by a list of values - # prefix: a list of prefix fields ['line_ids'] - # position: the line to process, skip is False if it's the first line of the current record - # OUT: - # (res, position, warning, res_id) with - # res: the record for the next line to process (including it's one2many) - # position: the new position for the next line - # res_id: the ID of the record if it's a modification - def process_liness(self, datas, prefix, current_module, model_name, fields_def, position=0, skip=0): - line = datas[position] - row = {} - warning = [] - data_res_id = False - xml_id = False - nbrmax = position+1 - - done = {} - for i, field in enumerate(fields): - res = False - if i >= len(line): - raise Exception(_('Please check that all your lines have %d columns.' - 'Stopped around line %d having %d columns.') % \ - (len(fields), position+2, len(line))) - if not line[i]: - continue - - if field[:len(prefix)] <> prefix: - if line[i] and skip: - return False - continue - field_name = field[len(prefix)] - - #set the mode for m2o, o2m, m2m : xml_id/id/name - if len(field) == len(prefix)+1: - mode = False - else: - mode = field[len(prefix)+1] - - # TODO: improve this by using csv.csv_reader - def many_ids(line, relation, current_module, mode): - res = [] - for db_id in line.split(config.get('csv_internal_sep')): - res.append(_get_id(relation, db_id, current_module, mode)) - return [(6,0,res)] - - # ID of the record using a XML ID - if field_name == 'id': - try: - data_res_id = _get_id(model_name, line[i], current_module) - except ValueError: - pass - xml_id = line[i] - continue - - # ID of the record using a database ID - elif field_name == '.id': - data_res_id = _get_id(model_name, line[i], current_module, '.id') - continue - - field_type = fields_def[field_name]['type'] - # recursive call for getting children and returning [(0,0,{})] or [(1,ID,{})] - if field_type == 'one2many': - if field_name in done: - continue - done[field_name] = True - relation = fields_def[field_name]['relation'] - relation_obj = self.pool.get(relation) - newfd = relation_obj.fields_get( cr, uid, context=context ) - pos = position - - res = [] - - first = 0 - while pos < len(datas): - res2 = process_liness(self, datas, prefix + [field_name], current_module, relation_obj._name, newfd, pos, first) - if not res2: - break - (newrow, pos, w2, data_res_id2, xml_id2) = res2 - nbrmax = max(nbrmax, pos) - warning += w2 - first += 1 - - if (not newrow) or not reduce(lambda x, y: x or y, newrow.values(), 0): - break - - res.append( (data_res_id2 and 1 or 0, data_res_id2 or 0, newrow) ) - - elif field_type == 'many2one': - relation = fields_def[field_name]['relation'] - res = _get_id(relation, line[i], current_module, mode) - - elif field_type == 'many2many': - relation = fields_def[field_name]['relation'] - res = many_ids(line[i], relation, current_module, mode) - - elif field_type == 'integer': - res = line[i] and int(line[i]) or 0 - elif field_type == 'boolean': - res = line[i].lower() not in ('0', 'false', 'off') - elif field_type == 'float': - res = line[i] and float(line[i]) or 0.0 - elif field_type == 'selection': - for key, val in fields_def[field_name]['selection']: - if tools.ustr(line[i]) in [tools.ustr(key), tools.ustr(val)]: - res = key - break - if line[i] and not res: - _logger.warning( - _("key '%s' not found in selection field '%s'"), - tools.ustr(line[i]), tools.ustr(field_name)) - warning.append(_("Key/value '%s' not found in selection field '%s'") % ( - tools.ustr(line[i]), tools.ustr(field_name))) - - else: - res = line[i] - - row[field_name] = res or False - - return row, nbrmax, warning, data_res_id, xml_id - - fields_def = self.fields_get(cr, uid, context=context) - - position = 0 if config.get('import_partial') and filename: with open(config.get('import_partial'), 'rb') as partial_import_file: data = pickle.load(partial_import_file) position = data.get(filename, 0) - while position o2m resolution skipped altogether, - # creates 2 records => remove/don't import columns sideshow columns, - # get completely different semantics - b, b1 = self.browse() + [b] = self.browse() self.assertEqual(b.const, 42) - self.assertEqual(values(b.value), []) - self.assertEqual(b1.const, 4) - self.assertEqual(values(b1.value), []) + # automatically forces link between core record and o2ms + self.assertEqual(values(b.value), [109, 262]) + self.assertEqual(values(b.value, field='parent_id'), [b, b]) def test_link_2(self): O2M_c = self.registry('export.one2many.child') @@ -838,21 +822,10 @@ class test_o2m(ImporterCase): ]), ok(2)) - (b,) = self.browse() - # if an id (db or xid) is provided, expectations that objects are - # *already* linked and emits UPDATE (1, id, {}). - # Noid => CREATE (0, ?, {}) - # TODO: xid ignored aside from getting corresponding db id? + [b] = self.browse() self.assertEqual(b.const, 42) - self.assertEqual(values(b.value), []) - - # FIXME: updates somebody else's records? - self.assertEqual( - O2M_c.read(self.cr, openerp.SUPERUSER_ID, id1), - {'id': id1, 'str': 'Bf', 'value': 1, 'parent_id': False}) - self.assertEqual( - O2M_c.read(self.cr, openerp.SUPERUSER_ID, id2), - {'id': id2, 'str': 'Me', 'value': 2, 'parent_id': False}) + self.assertEqual(values(b.value), [1, 2]) + self.assertEqual(values(b.value, field='parent_id'), [b, b]) class test_o2m_multiple(ImporterCase): model_name = 'export.one2many.multiple' @@ -866,16 +839,10 @@ class test_o2m_multiple(ImporterCase): ['', '14', ''], ]), ok(4)) - # Oh yeah, that's the stuff - (b, b1, b2) = self.browse() - self.assertEqual(values(b.child1), [11]) - self.assertEqual(values(b.child2), [21]) - self.assertEqual(values(b1.child1), [12]) - self.assertEqual(values(b1.child2), [22]) - - self.assertEqual(values(b2.child1), [13, 14]) - self.assertEqual(values(b2.child2), [23]) + [b] = self.browse() + self.assertEqual(values(b.child1), [11, 12, 13, 14]) + self.assertEqual(values(b.child2), [21, 22, 23]) def test_multi(self): self.assertEqual( @@ -888,11 +855,10 @@ class test_o2m_multiple(ImporterCase): ['', '', '23'], ]), ok(6)) - # What the actual fuck? - (b, b1) = self.browse() + + [b] = self.browse() self.assertEqual(values(b.child1), [11, 12, 13, 14]) - self.assertEqual(values(b.child2), [21]) - self.assertEqual(values(b1.child2), [22, 23]) + self.assertEqual(values(b.child2), [21, 22, 23]) def test_multi_fullsplit(self): self.assertEqual( @@ -906,12 +872,11 @@ class test_o2m_multiple(ImporterCase): ['', '', '23'], ]), ok(7)) - # oh wow - (b, b1) = self.browse() + + [b] = self.browse() self.assertEqual(b.const, 5) self.assertEqual(values(b.child1), [11, 12, 13, 14]) - self.assertEqual(b1.const, 36) - self.assertEqual(values(b1.child2), [21, 22, 23]) + self.assertEqual(values(b.child2), [21, 22, 23]) # function, related, reference: written to db as-is... # => function uses @type for value coercion/conversion diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index a5c3dcc5bbf..c141e62b65d 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -394,12 +394,14 @@ class test_unbound_string_field(ImporterCase): class test_required_string_field(ImporterCase): model_name = 'export.string.required' + @mute_logger('openerp.sql_db') def test_empty(self): result = self.import_(['value'], [[]]) self.assertEqual(result['messages'], [message( u"Missing required value for the field 'unknown'")]) self.assertIs(result['ids'], False) + @mute_logger('openerp.sql_db') def test_not_provided(self): result = self.import_(['const'], [['12']]) self.assertEqual(result['messages'], [message( @@ -920,7 +922,7 @@ class test_o2m_multiple(ImporterCase): ]) self.assertFalse(result['messages']) self.assertEqual(len(result['ids']), 1) - # Oh yeah, that's the stuff + [b] = self.browse() self.assertEqual(values(b.child1), [11, 12, 13, 14]) self.assertEqual(values(b.child2), [21, 22, 23]) From aaff4459c438bdbb4b2d5f62bbd95a155904136c Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Fri, 28 Sep 2012 08:56:54 +0200 Subject: [PATCH 21/43] [DOC] fixes and cleanups bzr revid: xmo@openerp.com-20120928065654-jj3ot17dz8vd3wum --- .bzrignore | 31 ++++++---- doc/api/user_img_specs.rst | 25 ++++++-- doc/conf.py | 24 +++---- openerp/osv/expression.py | 124 ++++++++++++++++++++----------------- openerp/osv/orm.py | 12 +++- openerp/osv/query.py | 14 ++--- 6 files changed, 135 insertions(+), 95 deletions(-) diff --git a/.bzrignore b/.bzrignore index ba5258506be..8531ad42bbb 100644 --- a/.bzrignore +++ b/.bzrignore @@ -1,14 +1,19 @@ -.* -*.egg-info -*.orig -*.vim +.*.swp +.bzrignore +.idea +.project +.pydevproject +.ropeproject +.settings +.DS_Store +openerp/addons/* +openerp/filestore* +.Python +*.pyc +*.pyo +bin/* build/ -RE:^bin/ -RE:^dist/ -RE:^include/ - -RE:^share/ -RE:^man/ -RE:^lib/ - -RE:^doc/_build/ +include/ +lib/ +share/ +doc/_build/* diff --git a/doc/api/user_img_specs.rst b/doc/api/user_img_specs.rst index a915529accd..c6729eed920 100644 --- a/doc/api/user_img_specs.rst +++ b/doc/api/user_img_specs.rst @@ -3,9 +3,24 @@ User avatar .. versionadded:: 7.0 -This revision adds an avatar for users. This replaces the use of gravatar to emulate avatars, used in views like the tasks kanban view. Two fields have been added to the res.users model: - - avatar_big, a binary field holding the image. It is base-64 encoded, and PIL-supported. Images stored are resized to 540x450 px, to limitate the binary field size. - - avatar, a function binary field holding an automatically resized version of the avatar_big field. It is also base-64 encoded, and PIL-supported. Dimensions of the resized avatar are 180x150. This field is used as an inteface to get and set the user avatar. -When changing the avatar through the avatar function field, the new image is automatically resized to 540x450, and stored in the avatar_big field. This triggers the function field, that will compute a 180x150 resized version of the image. +This revision adds an avatar for users. This replaces the use of +gravatar to emulate avatars, used in views like the tasks kanban +view. Two fields have been added to the res.users model: -An avatar field has been added to the users form view, as well as in Preferences. When creating a new user, a default avatar is chosen among 6 possible default images. +* ``avatar_big``, a binary field holding the image. It is base-64 + encoded, and PIL-supported. Images stored are resized to 540x450 px, + to limitate the binary field size. + +* ``avatar``, a function binary field holding an automatically resized + version of the avatar_big field. It is also base-64 encoded, and + PIL-supported. Dimensions of the resized avatar are 180x150. This + field is used as an inteface to get and set the user avatar. + +When changing the avatar through the avatar function field, the new +image is automatically resized to 540x450, and stored in the +avatar_big field. This triggers the function field, that will compute +a 180x150 resized version of the image. + +An avatar field has been added to the users form view, as well as in +Preferences. When creating a new user, a default avatar is chosen +among 6 possible default images. diff --git a/doc/conf.py b/doc/conf.py index a5ff6bed7e1..ac5b8907e0e 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -16,9 +16,10 @@ import sys, os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) sys.path.append(os.path.abspath('_themes')) -sys.path.insert(0, os.path.abspath('../addons')) -sys.path.insert(0, os.path.abspath('..')) +sys.path.append(os.path.abspath('..')) +sys.path.append(os.path.abspath('../openerp')) # -- General configuration ----------------------------------------------------- @@ -42,7 +43,7 @@ source_suffix = '.rst' master_doc = 'index' # General information about the project. -project = u'OpenERP Web Developers Documentation' +project = u'OpenERP Server Developers Documentation' copyright = u'2012, OpenERP s.a.' # The version info for the project you're documenting, acts as replacement for @@ -50,9 +51,9 @@ copyright = u'2012, OpenERP s.a.' # built documents. # # The short X.Y version. -version = '6.1' +version = '7.0' # The full version, including alpha/beta/rc tags. -release = '6.1' +release = '7.0b' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -170,7 +171,7 @@ html_sidebars = { #html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'openerp-web-doc' +htmlhelp_basename = 'openerp-server-doc' # -- Options for LaTeX output -------------------------------------------------- @@ -189,7 +190,7 @@ latex_elements = { # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'openerp-web-doc.tex', u'OpenERP Web Developers Documentation', + ('index', 'openerp-server-doc.tex', u'OpenERP Server Developers Documentation', u'OpenERP s.a.', 'manual'), ] @@ -219,7 +220,7 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'openerp-web-doc', u'OpenERP Web Developers Documentation', + ('index', 'openerp-server-doc', u'OpenERP Server Developers Documentation', [u'OpenERP s.a.'], 1) ] @@ -233,8 +234,8 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'OpenERPWebDocumentation', u'OpenERP Web Developers Documentation', - u'OpenERP s.a.', 'OpenERPWebDocumentation', 'Developers documentation for the openerp-web project.', + ('index', 'OpenERPServerDocumentation', u'OpenERP Server Developers Documentation', + u'OpenERP s.a.', 'OpenERPServerDocumentation', 'Developers documentation for the openobject-server project.', 'Miscellaneous'), ] @@ -247,11 +248,10 @@ texinfo_documents = [ # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' -todo_include_todos = True # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { 'python': ('http://docs.python.org/', None), - 'openerpserver': ('http://doc.openerp.com/trunk/developers/server', None), + 'openerpweb': ('http://doc.openerp.com/trunk/developers/web', None), 'openerpdev': ('http://doc.openerp.com/trunk/developers', None), } diff --git a/openerp/osv/expression.py b/openerp/osv/expression.py index e2a3391c8a4..5f2c0b915c1 100644 --- a/openerp/osv/expression.py +++ b/openerp/osv/expression.py @@ -22,102 +22,114 @@ """ Domain expression processing -The main duty of this module is to compile a domain expression into a SQL -query. A lot of things should be documented here, but as a first step in the -right direction, some tests in test_osv_expression.yml might give you some -additional information. +The main duty of this module is to compile a domain expression into a +SQL query. A lot of things should be documented here, but as a first +step in the right direction, some tests in test_osv_expression.yml +might give you some additional information. -For legacy reasons, a domain uses an inconsistent two-levels abstract syntax -(domains are regular Python data structures). At the first level, a domain -is an expression made of terms (sometimes called leaves) and (domain) operators -used in prefix notation. The available operators at this level are '!', '&', -and '|'. '!' is a unary 'not', '&' is a binary 'and', and '|' is a binary 'or'. -For instance, here is a possible domain. ( stands for an arbitrary term, -more on this later.) +For legacy reasons, a domain uses an inconsistent two-levels abstract +syntax (domains are regular Python data structures). At the first +level, a domain is an expression made of terms (sometimes called +leaves) and (domain) operators used in prefix notation. The available +operators at this level are '!', '&', and '|'. '!' is a unary 'not', +'&' is a binary 'and', and '|' is a binary 'or'. For instance, here +is a possible domain. ( stands for an arbitrary term, more on +this later.):: ['&', '!', , '|', , ] -It is equivalent to this pseudo code using infix notation: +It is equivalent to this pseudo code using infix notation:: (not ) and ( or ) -The second level of syntax deals with the term representation. A term is -a triple of the form (left, operator, right). That is, a term uses an infix -notation, and the available operators, and possible left and right operands -differ with those of the previous level. Here is a possible term: +The second level of syntax deals with the term representation. A term +is a triple of the form (left, operator, right). That is, a term uses +an infix notation, and the available operators, and possible left and +right operands differ with those of the previous level. Here is a +possible term:: ('company_id.name', '=', 'OpenERP') -The left and right operand don't have the same possible values. The left -operand is field name (related to the model for which the domain applies). -Actually, the field name can use the dot-notation to traverse relationships. -The right operand is a Python value whose type should match the used operator -and field type. In the above example, a string is used because the name field -of a company has type string, and because we use the '=' operator. When -appropriate, a 'in' operator can be used, and thus the right operand should be -a list. +The left and right operand don't have the same possible values. The +left operand is field name (related to the model for which the domain +applies). Actually, the field name can use the dot-notation to +traverse relationships. The right operand is a Python value whose +type should match the used operator and field type. In the above +example, a string is used because the name field of a company has type +string, and because we use the '=' operator. When appropriate, a 'in' +operator can be used, and thus the right operand should be a list. -Note: the non-uniform syntax could have been more uniform, but this would hide -an important limitation of the domain syntax. Say that the term representation -was ['=', 'company_id.name', 'OpenERP']. Used in a complete domain, this would -look like: +Note: the non-uniform syntax could have been more uniform, but this +would hide an important limitation of the domain syntax. Say that the +term representation was ['=', 'company_id.name', 'OpenERP']. Used in a +complete domain, this would look like:: - ['!', ['=', 'company_id.name', 'OpenERP']] + ['!', ['=', 'company_id.name', 'OpenERP']] -and you would be tempted to believe something like this would be possible: +and you would be tempted to believe something like this would be +possible:: - ['!', ['=', 'company_id.name', ['&', ..., ...]]] + ['!', ['=', 'company_id.name', ['&', ..., ...]]] -That is, a domain could be a valid operand. But this is not the case. A domain -is really limited to a two-level nature, and can not take a recursive form: a -domain is not a valid second-level operand. +That is, a domain could be a valid operand. But this is not the +case. A domain is really limited to a two-level nature, and can not +take a recursive form: a domain is not a valid second-level operand. Unaccent - Accent-insensitive search -OpenERP will use the SQL function 'unaccent' when available for the 'ilike' and -'not ilike' operators, and enabled in the configuration. -Normally the 'unaccent' function is obtained from the PostgreSQL 'unaccent' -contrib module[0]. +OpenERP will use the SQL function 'unaccent' when available for the +'ilike' and 'not ilike' operators, and enabled in the configuration. +Normally the 'unaccent' function is obtained from `the PostgreSQL +'unaccent' contrib module +`_. +.. todo: The following explanation should be moved in some external + installation guide -..todo: The following explanation should be moved in some external installation - guide +The steps to install the module might differ on specific PostgreSQL +versions. We give here some instruction for PostgreSQL 9.x on a +Ubuntu system. -The steps to install the module might differ on specific PostgreSQL versions. -We give here some instruction for PostgreSQL 9.x on a Ubuntu system. +Ubuntu doesn't come yet with PostgreSQL 9.x, so an alternative package +source is used. We use Martin Pitt's PPA available at +`ppa:pitti/postgresql +`_. -Ubuntu doesn't come yet with PostgreSQL 9.x, so an alternative package source -is used. We use Martin Pitt's PPA available at ppa:pitti/postgresql[1]. See -[2] for instructions. Basically: +.. code-block:: sh > sudo add-apt-repository ppa:pitti/postgresql > sudo apt-get update -Once the package list is up-to-date, you have to install PostgreSQL 9.0 and -its contrib modules. +Once the package list is up-to-date, you have to install PostgreSQL +9.0 and its contrib modules. + +.. code-block:: sh > sudo apt-get install postgresql-9.0 postgresql-contrib-9.0 When you want to enable unaccent on some database: +.. code-block:: sh + > psql9 -f /usr/share/postgresql/9.0/contrib/unaccent.sql -Here 'psql9' is an alias for the newly installed PostgreSQL 9.0 tool, together -with the correct port if necessary (for instance if PostgreSQL 8.4 is running -on 5432). (Other aliases can be used for createdb and dropdb.) +Here :program:`psql9` is an alias for the newly installed PostgreSQL +9.0 tool, together with the correct port if necessary (for instance if +PostgreSQL 8.4 is running on 5432). (Other aliases can be used for +createdb and dropdb.) + +.. code-block:: sh > alias psql9='/usr/lib/postgresql/9.0/bin/psql -p 5433' You can check unaccent is working: +.. code-block:: sh + > psql9 -c"select unaccent('hélène')" Finally, to instruct OpenERP to really use the unaccent function, you have to -start the server specifying the --unaccent flag. - -[0] http://developer.postgresql.org/pgdocs/postgres/unaccent.html -[1] https://launchpad.net/~pitti/+archive/postgresql -[2] https://launchpad.net/+help/soyuz/ppa-sources-list.html +start the server specifying the ``--unaccent`` flag. """ @@ -232,7 +244,7 @@ def is_leaf(element, internal=False): """ Test whether an object is a valid domain term. :param internal: allow or not the 'inselect' internal operator in the term. - This normally should be always left to False. + This normally should be always left to False. """ INTERNAL_OPS = TERM_OPERATORS + ('inselect',) return (isinstance(element, tuple) or isinstance(element, list)) \ diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index 934b6eaf2e4..afabda817f4 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1218,7 +1218,11 @@ class BaseModel(object): return {'datas': datas} def import_data(self, cr, uid, fields, datas, mode='init', current_module='', noupdate=False, context=None, filename=None): - """Import given data in given module + """ + .. deprecated:: 7.0 + Use :meth:`~load` instead + + Import given data in given module This method is used when importing data via client menu. @@ -1302,6 +1306,10 @@ class BaseModel(object): ``False`` if there was an error and no id could be generated) and a list of messages. + The ids are those of the records created and saved (in database), in + the same order they were extracted from the file. They can be passed + directly to :meth:`~read` + Each message is a dictionary with the following keys: ``type`` @@ -5065,7 +5073,7 @@ class BaseModel(object): def is_transient(self): """ Return whether the model is transient. - See TransientModel. + See :class:`TransientModel`. """ return self._transient diff --git a/openerp/osv/query.py b/openerp/osv/query.py index 0b2bf47305a..252b301d531 100644 --- a/openerp/osv/query.py +++ b/openerp/osv/query.py @@ -73,15 +73,15 @@ class Query(object): :param connection: a tuple ``(lhs, table, lhs_col, col)``. The join corresponds to the SQL equivalent of:: - ``(lhs.lhs_col = table.col)`` + (lhs.lhs_col = table.col) :param outer: True if a LEFT OUTER JOIN should be used, if possible (no promotion to OUTER JOIN is supported in case the JOIN - was already present in the query, as for the moment - implicit INNER JOINs are only connected from NON-NULL - columns so it would not be correct (e.g. for - ``_inherits`` or when a domain criterion explicitly - adds filtering) + was already present in the query, as for the moment + implicit INNER JOINs are only connected from NON-NULL + columns so it would not be correct (e.g. for + ``_inherits`` or when a domain criterion explicitly + adds filtering) """ (lhs, table, lhs_col, col) = connection lhs = _quote(lhs) @@ -120,4 +120,4 @@ class Query(object): def __str__(self): return '' % self.get_sql() -# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: \ No newline at end of file +# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: From 879886decd0a64ac2e8d62bb9a6e409aa7f8abe8 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Fri, 28 Sep 2012 11:15:19 +0200 Subject: [PATCH 22/43] [ADD] some import doc bzr revid: xmo@openerp.com-20120928091519-ksdprqdq0tpklggw --- doc/import.rst | 231 +++++++++++++++++++++++++++++++++++++++++++++ doc/index.rst.inc | 1 + doc/o2m.txt | 13 +++ openerp/osv/orm.py | 31 ------ 4 files changed, 245 insertions(+), 31 deletions(-) create mode 100644 doc/import.rst create mode 100644 doc/o2m.txt diff --git a/doc/import.rst b/doc/import.rst new file mode 100644 index 00000000000..f3db5d50bcf --- /dev/null +++ b/doc/import.rst @@ -0,0 +1,231 @@ +.. _bulk-import: + +Bulk Import +=========== + +OpenERP has included a bulk import facility for CSV-ish files for a +long time. With 7.0, both the interface and internal implementation +have been redone, resulting in +:meth:`~openerp.osv.orm.BaseModel.load`. + +.. note:: + + the previous bulk-loading method, + :meth:`~openerp.osv.orm.BaseModel.import_data`, remains for + backwards compatibility but was re-implemented on top of + :meth:`~openerp.osv.orm.BaseModel.load`, while its interface is + unchanged its precise behavior has likely been altered for some + cases (it shouldn't throw exceptions anymore in many cases where + it previously did) + +This document attempts to explain the behavior and limitations of +:meth:`~openerp.osv.orm.BaseModel.load`. + +Data +==== + +The input ``data`` is a regular row-major matrix of strings (in Python +datatype terms, a ``list`` of rows, each row being a ``list`` of +``str``, all rows must be of equal length). Each row must be the same +length as the ``fields`` list preceding it in the argslist. + +Each field of ``fields`` maps to a (potentially relational and nested) +field of the model under import, and the corresponding column of the +``data`` matrix provides a value for the field for each record. + +Generally speaking each row of the input yields a record of output, +and each cell of a row yields a value for the corresponding field of +the row's record. There is currently one exception for this rule: + +One to Many fields +------------------ + +Because O2M fields contain multiple records "embedded" in the main +one, and these sub-records are fully dependent on the main record (are +no other references to the sub-records in the system), they have to be +spliced into the matrix somehow. This is done by adding lines composed +*only* of o2m record fields below the main record: + +.. literalinclude:: o2m.txt + +the sections in double-lines represent the span of two o2m +fields. During parsing, they are extracted into their own ``data`` +matrix for the o2m field they correspond to. + +Import process +============== + +Here are the phases of import. Note that the concept of "phases" is +fuzzy as it's currently more of a pipeline, each record moves through +the entire pipeline before the next one is processed. + +Extraction +---------- + +The first phase of the import is the extraction of the current row +(and potentially a section of rows following it if it has One to Many +fields) into a record dictionary. The keys are the ``fields`` +originally passed to :meth:`~openerp.osv.orm.BaseModel.load`, and the +values are either the string value at the corresponding cell (for +non-relational fields) or a list of sub-records (for all relational +fields). + +This phase also generates the ``rows`` indexes for any +:ref:`import-message` produced thereafter. + +Conversion +---------- + +This second phase takes the record dicts, extracts the :ref:`dbid` and +:ref:`xid` if present and attempts to convert each field to a type +matching what OpenERP expects to write. Empty fields (empty strings) +are replaced with the ``False`` value, and non-empty fields are +converted through +:class:`~openerp.addons.base.ir.ir_fields.ir_fields_converter`: + +Char, text and binary fields +++++++++++++++++++++++++++++ + +Are returned as-is, without any alteration. + +Boolean fields +++++++++++++++ + +The string value is compared (in a case-insensitive manner) to ``0``, +``false`` and ``no`` as well of any translation thereof loaded in the +database. If the value matches one of these, the field is set to +``False``. + +Otherwise the field is compared to ``1``, ``true`` and ``yes`` (and +any translation of these in the database). The field is always set to +``True``, but if the value does not match one of these a warning will +also be output. + +Integers and float fields ++++++++++++++++++++++++++ + +The field is parsed with Python's built-in conversion routines +(``int`` and ``float`` respectively), if the conversion fails an error +is generated. + +Selection fields +++++++++++++++++ + +The field is compared to 1. the values of the selection (first part of +each selection tuple) and 2. all translations of the selection label +found in the database. + +If one of these is matched, the corresponding value is set on the +field. + +Otherwise an error is generated. + +The same process applies to both list-type and function-type selection +fields. + +Many to One field ++++++++++++++++++ + +If the specified field is the relational field itself (``m2o``), the +value is used in a ``name_search``. The first record returned by +``name_search`` is used as the field's value. + +If ``name_search`` finds no value, an error is generated. If +``name_search`` finds multiple value, a warning is generated to warn +the user of ``name_search`` collisions. + +If the specified field is a :ref:`xid` (``m2o/id``), the +corresponding record it looked up in the database and used as the +field's value. If no record is found matching the provided external +ID, an error is generated. + +If the specified field is a :ref:`dbid` (``m2o/.id``), the process is +the same as for external ids (on database identifiers instead of +external ones). + +Many to Many field +++++++++++++++++++ + +The field's value is interpreted as a comma-separated list of names, +external ids or database ids. For each one, the process previously +used for the many to one field is applied. + +One to Many field ++++++++++++++++++ + +For each o2m record extracted, if the record has a ``name``, +:ref:`xid` or :ref:`dbid` the :ref:`dbid` is looked up and checked +through the same process as for m2o fields. + +If a :ref:`dbid` was found, a LINK_TO command is emmitted, followed by +an UPDATE with the non-db values for the relational field. + +Otherwise a CREATE command is emmitted. + +Create/Write +------------ + +If the conversion was successful, the converted record is then saved +to the database via ``(ir.model.data)._update``. + +Error handling +-------------- + +The import process will only catch 2 types of exceptions to convert +them to error messages: ``ValueError`` during the conversion process, +and sub-exceptions of ``psycopg2.Error`` during the create/write +process. + +The import process uses savepoint to: + +* protect the overall transaction from the failure of each ``_update`` + call, if an ``_update`` call fails the savepoint is rolled back and + the import process keeps going in order to obtain as many error + messages as possible during each run. + +* protect the import as a whole, a savepoint is created before + starting and if any error is generated that savepoint is rolled + back. The rest of the transaction (anything not within the import + process) will be left untouched. + +.. _import-message: +.. _import-messages: + +Messages +======== + +A message is a dictionary with 5 mandatory keys and one optional key: + +``type`` + the type of message, either ``warning`` or ``error``. Any + ``error`` message indicates the import failed and was rolled back. + +``message`` + the message's actual text, which should be translated and can be + shown to the user directly + +``rows`` + a dict with 2 keys ``from`` and ``to``, indicates the range of + rows in ``data`` which generated the message + +``record`` + a single integer, for warnings the index of the record which + generated the message (can be obtained from a non-false ``ids`` + result) + +``field`` + the name of the (logical) OpenERP field for which the error or + warning was generated + +``moreinfo`` (optional) + A string, a list or a dict, leading to more information about the + warning. + + * If ``moreinfo`` is a string, it is a supplementary warnings + message which should be hidden by default + * If ``moreinfo`` is a list, it provides a number of possible or + alternative values for the string + * If ``moreinfo`` is a dict, it is an OpenERP action descriptor + which can be executed to get more information about the issues + with the field. If present, the ``help`` key serves as a label + for the action (e.g. the text of the link). diff --git a/doc/index.rst.inc b/doc/index.rst.inc index 6182f6251e9..842888aecb2 100644 --- a/doc/index.rst.inc +++ b/doc/index.rst.inc @@ -5,6 +5,7 @@ OpenERP Server .. toctree:: :maxdepth: 1 + import test-framework Changed in 7.0 diff --git a/doc/o2m.txt b/doc/o2m.txt new file mode 100644 index 00000000000..32218e5ac85 --- /dev/null +++ b/doc/o2m.txt @@ -0,0 +1,13 @@ ++-------+-------+===========+===========+-------+-------+ +|value01|value02‖o2m/value01|o2m/value02‖value03|value04| ++-------+-------+-----------+-----------+-------+-------+ +| | ‖o2m/value11|o2m/value12‖ | | ++-------+-------+-----------+-----------+-------+-------+ +| | ‖o2m/value21|o2m/value22‖ | | ++-------+-------+===========+===========+-------+-------+ +|value11|value12‖o2m/value01|o2m/value02‖value13|value14| ++-------+-------+-----------+-----------+-------+-------+ +| | ‖o2m/value11|o2m/value12‖ | | ++-------+-------+===========+===========+-------+-------+ +|value21|value22| | |value23|value24| ++-------+-------+-----------+-----------+-------+-------+ diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index afabda817f4..da0c93762bc 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1310,37 +1310,6 @@ class BaseModel(object): the same order they were extracted from the file. They can be passed directly to :meth:`~read` - Each message is a dictionary with the following keys: - - ``type`` - the type of message, either ``warning`` or ``error``. Any ``error`` - message indicates the import failed and was rolled back. - ``message`` - the message's actual text, which should be translated and can be - shown to the user directly - ``rows`` - a dict with 2 keys ``from`` and ``to``, indicates the range of rows - in ``data`` which generated the message - ``record`` - a single integer, for warnings the index of the record which - generated the message (can be obtained from a non-false ``ids`` - result) - ``field`` - the name of the (logical) OpenERP field for which the error or - warning was generated - ``moreinfo`` (optional) - A string, a list or a dict, leading to more information about the - warning. - - * If ``moreinfo`` is a string, it is a supplementary warnings - message which should be hidden by default - * If ``moreinfo`` is a list, it provides a number of possible or - alternative values for the string - * If ``moreinfo`` is a dict, it is an OpenERP action descriptor - which can be executed to get more information about the issues - with the field. If present, the ``help`` key serves as a label - for the action (e.g. the text of the link). - :param cr: cursor for the request :param int uid: ID of the user attempting the data import :param fields: list of fields to import, at the same index as the corresponding data From 84332d8aad36c92f91f21e9533974310f15481c6 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 1 Oct 2012 15:05:30 +0200 Subject: [PATCH 23/43] [FIX] type error when providing a non-integer for a default database id bzr revid: xmo@openerp.com-20121001130530-yf6pzuxdi3s09tji --- openerp/addons/base/ir/ir_fields.py | 32 +++++++++++-------- openerp/osv/orm.py | 12 +++++-- .../addons/test_impex/tests/test_load.py | 10 ++++++ 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 7a4c34c7f30..c7012525961 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -2,6 +2,7 @@ import functools import operator import itertools +import psycopg2 from openerp.osv import orm, fields from openerp.tools.translate import _ @@ -179,14 +180,27 @@ class ir_fields_converter(orm.Model): if context is None: context = {} id = None warnings = [] + extras = {'moreinfo': { + 'type': 'ir.actions.act_window', 'target': 'new', + 'res_model': column._obj, 'view_mode': 'tree,form', + 'view_type': 'form', + 'views': [(False, 'tree', (False, 'form'))], + 'help': _(u"See all possible values") + }} RelatedModel = self.pool[column._obj] if subfield == '.id': field_type = _(u"database id") try: tentative_id = int(value) except ValueError: tentative_id = value - if RelatedModel.search(cr, uid, [('id', '=', tentative_id)], - context=context): - id = tentative_id + try: + if RelatedModel.search(cr, uid, [('id', '=', tentative_id)], + context=context): + id = tentative_id + except psycopg2.DataError: + # type error + raise ValueError( + _(u"Invalid database id '%s' for the field '%%(field)s'") % value, + extras) elif subfield == 'id': field_type = _(u"external id") if '.' in value: @@ -217,17 +231,7 @@ class ir_fields_converter(orm.Model): if id is None: raise ValueError( _(u"No matching record found for %(field_type)s '%(value)s' in field '%%(field)s'") - % {'field_type': field_type, 'value': value}, { - 'moreinfo': { - 'type': 'ir.actions.act_window', - 'target': 'new', - 'res_model': column._obj, - 'view_mode': 'tree,form', - 'view_type': 'form', - 'views': [(False, 'tree', (False, 'form'))], - 'help': _(u"See all possible values") - } - }) + % {'field_type': field_type, 'value': value}, extras) return id, field_type, warnings def _referencing_subfield(self, record): diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index da0c93762bc..9a25e621023 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1335,7 +1335,15 @@ class BaseModel(object): self._extract_records(cr, uid, fields, data, context=context, log=messages.append), context=context, log=messages.append): - cr.execute('SAVEPOINT model_load_save') + try: + cr.execute('SAVEPOINT model_load_save') + except psycopg2.InternalError, e: + # broken transaction, exit and hope the source error was + # already logged + if not any(message['type'] == 'error' for message in messages): + messages.append(dict(info, type='error',message= + u"Unknown database error: '%s'" % e)) + break try: ids.append(ModelData._update(cr, uid, self._name, current_module, record, mode=mode, xml_id=xid, @@ -1349,7 +1357,7 @@ class BaseModel(object): # avoid broken transaction) and keep going cr.execute('ROLLBACK TO SAVEPOINT model_load_save') messages.append(dict( - info, type="error", + info, type='error', **PGERROR_TO_OE[e.pgcode](self, fg, info, e))) if any(message['type'] == 'error' for message in messages): cr.execute('ROLLBACK TO SAVEPOINT model_load') diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index c141e62b65d..074b75a9fb3 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -619,6 +619,16 @@ class test_m2o(ImporterCase): for index, id in enumerate([integer_id1, integer_id2, integer_id1])]) self.assertIs(result['ids'], False) + @mute_logger('openerp.sql_db') + def test_fail_id_mistype(self): + result = self.import_(['value/.id'], [["foo"]]) + + self.assertEqual(result['messages'], [ + message(u"Invalid database id 'foo' for the field 'unknown'", + moreinfo=moreaction('export.integer')) + ]) + self.assertIs(result['ids'], False) + def test_sub_field(self): """ Does not implicitly create the record, does not warn that you can't import m2o subfields (at all)... From aed20ca5d7a19fb5497c65245f5a801723c2c91e Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 1 Oct 2012 15:43:30 +0200 Subject: [PATCH 24/43] [FIX] conversion and validation of date and datetime field imports bzr revid: xmo@openerp.com-20121001134330-za8hqctrghmw08i4 --- openerp/addons/base/ir/ir_fields.py | 24 +++++++-- .../addons/test_impex/tests/test_load.py | 49 ++++++++++++++++--- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index c7012525961..417fbc9b4db 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -3,8 +3,11 @@ import functools import operator import itertools import psycopg2 +import time from openerp.osv import orm, fields from openerp.tools.translate import _ +from openerp.tools.misc import DEFAULT_SERVER_DATE_FORMAT,\ + DEFAULT_SERVER_DATETIME_FORMAT REFERENCING_FIELDS = set([None, 'id', '.id']) def only_ref_fields(record): @@ -114,14 +117,25 @@ class ir_fields_converter(orm.Model): _(u"'%s' does not seem to be a number for field '%%(field)s'") % value) - def _str_to_char(self, cr, uid, model, column, value, context=None): + def _str_id(self, cr, uid, model, column, value, context=None): return value, [] + _str_to_char = _str_to_text = _str_to_binary = _str_id - def _str_to_text(self, cr, uid, model, column, value, context=None): - return value, [] + def _str_to_date(self, cr, uid, model, column, value, context=None): + try: + time.strptime(value, DEFAULT_SERVER_DATE_FORMAT) + return value, [] + except ValueError: + raise ValueError( + _(u"'%s' does not seem to be a valid date for field '%%(field)s'") % value) - def _str_to_binary(self, cr, uid, model, column, value, context=None): - return value, [] + def _str_to_datetime(self, cr, uid, model, column, value, context=None): + try: + time.strptime(value, DEFAULT_SERVER_DATETIME_FORMAT) + return value, [] + except ValueError: + raise ValueError( + _(u"'%s' does not seem to be a valid datetime for field '%%(field)s'") % value) def _get_translations(self, cr, uid, types, src, context): types = tuple(types) diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 074b75a9fb3..7657709d24a 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -339,13 +339,8 @@ class test_float_field(ImporterCase): def test_nonsense(self): result = self.import_(['value'], [['foobar']]) self.assertIs(result['ids'], False) - self.assertEqual(result['messages'], [{ - 'type': 'error', - 'rows': {'from': 0, 'to': 0}, - 'record': 0, - 'field': 'value', - 'message': u"'foobar' does not seem to be a number for field 'unknown'", - }]) + self.assertEqual(result['messages'], [ + message(u"'foobar' does not seem to be a number for field 'unknown'")]) class test_string_field(ImporterCase): model_name = 'export.string.bounded' @@ -981,5 +976,45 @@ class test_realworld(common.TransactionCase): self.assertFalse(result['messages']) self.assertEqual(len(result['ids']), len(data)) +class test_date(ImporterCase): + model_name = 'export.date' + + def test_empty(self): + self.assertEqual( + self.import_(['value'], []), + {'ids': [], 'messages': []}) + + def test_basic(self): + result = self.import_(['value'], [['2012-02-03']]) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) + + def test_invalid(self): + result = self.import_(['value'], [['not really a date']]) + self.assertEqual(result['messages'], [ + message(u"'not really a date' does not seem to be a valid date " + u"for field 'unknown'")]) + self.assertIs(result['ids'], False) + +class test_datetime(ImporterCase): + model_name = 'export.datetime' + + def test_empty(self): + self.assertEqual( + self.import_(['value'], []), + {'ids': [], 'messages': []}) + + def test_basic(self): + result = self.import_(['value'], [['2012-02-03 11:11:11']]) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) + + def test_invalid(self): + result = self.import_(['value'], [['not really a datetime']]) + self.assertEqual(result['messages'], [ + message(u"'not really a datetime' does not seem to be a valid " + u"datetime for field 'unknown'")]) + self.assertIs(result['ids'], False) + # function, related, reference: written to db as-is... # => function uses @type for value coercion/conversion From b9b19f61eab893ea27f16dcdf5c15621bf0c7636 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Wed, 3 Oct 2012 13:59:49 +0200 Subject: [PATCH 25/43] [FIX] if a selection label is empty, return the value in a selection import message bzr revid: xmo@openerp.com-20121003115949-sgsouhcmboascjbl --- openerp/addons/base/ir/ir_fields.py | 3 ++- openerp/tests/addons/test_impex/models.py | 2 +- openerp/tests/addons/test_impex/tests/test_load.py | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 417fbc9b4db..65d4a786178 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -168,7 +168,8 @@ class ir_fields_converter(orm.Model): raise ValueError( _(u"Value '%s' not found in selection field '%%(field)s'") % ( value), { - 'moreinfo': map(operator.itemgetter(1), selection) + 'moreinfo': [label or unicode(item) for item, label in selection + if label or item] }) diff --git a/openerp/tests/addons/test_impex/models.py b/openerp/tests/addons/test_impex/models.py index c56ddc649e2..3c85b56fff7 100644 --- a/openerp/tests/addons/test_impex/models.py +++ b/openerp/tests/addons/test_impex/models.py @@ -22,7 +22,7 @@ models = [ ('date', fields.date()), ('datetime', fields.datetime()), ('text', fields.text()), - ('selection', fields.selection([(1, "Foo"), (2, "Bar"), (3, "Qux")])), + ('selection', fields.selection([(1, "Foo"), (2, "Bar"), (3, "Qux"), (4, '')])), ('selection.function', fields.selection(selection_fn)), # just relate to an integer ('many2one', fields.many2one('export.integer')), diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 7657709d24a..18b26a16354 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -466,13 +466,13 @@ class test_selection(ImporterCase): self.assertIs(result['ids'], False) self.assertEqual(result['messages'], [message( u"Value 'Baz' not found in selection field 'unknown'", - moreinfo="Foo Bar Qux".split())]) + moreinfo="Foo Bar Qux 4".split())]) result = self.import_(['value'], [[42]]) self.assertIs(result['ids'], False) self.assertEqual(result['messages'], [message( u"Value '42' not found in selection field 'unknown'", - moreinfo="Foo Bar Qux".split())]) + moreinfo="Foo Bar Qux 4".split())]) class test_selection_function(ImporterCase): model_name = 'export.selection.function' From 601568f5f9431338886aa1aeda240a7137f16173 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 4 Oct 2012 08:42:15 +0200 Subject: [PATCH 26/43] [FIX] don't import empty cells at all rather than set them to False, to allow defaults handling to do its job before actually creating the record bzr revid: xmo@openerp.com-20121004064215-fqgir3ovmte2v438 --- openerp/osv/orm.py | 1 - openerp/tests/addons/test_impex/models.py | 10 ++++++++++ .../tests/addons/test_impex/tests/test_load.py | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index 4f21a159cdd..b0b0fad88fa 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1501,7 +1501,6 @@ class BaseModel(object): for field, strvalue in record.iteritems(): if field in (None, 'id', '.id'): continue if not strvalue: - converted[field] = False continue # In warnings and error messages, use translated string as diff --git a/openerp/tests/addons/test_impex/models.py b/openerp/tests/addons/test_impex/models.py index 3c85b56fff7..50340082d08 100644 --- a/openerp/tests/addons/test_impex/models.py +++ b/openerp/tests/addons/test_impex/models.py @@ -123,3 +123,13 @@ class Many2ManyChild(orm.Model): , context=context) if isinstance(name, basestring) and name.split(':')[0] == self._name else []) + +class SelectionWithDefault(orm.Model): + _name = 'export.selection.withdefault' + + _columns = { + 'value': fields.selection([(1, "Foo"), (2, "Bar")], required=True), + } + _defaults = { + 'value': 2, + } diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 18b26a16354..65c4b6e9e57 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -474,6 +474,21 @@ class test_selection(ImporterCase): u"Value '42' not found in selection field 'unknown'", moreinfo="Foo Bar Qux 4".split())]) +class test_selection_with_default(ImporterCase): + model_name = 'export.selection.withdefault' + + def test_skip_empty(self): + """ Empty cells should be entirely skipped so that default values can + be inserted by the ORM + """ + result = self.import_(['value'], [['']]) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) + + self.assertEqual( + values(self.read()), + [2]) + class test_selection_function(ImporterCase): model_name = 'export.selection.function' translations_fr = [ From e77fad774b7c2d8c450259b9d35d9540c44e7997 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 4 Oct 2012 13:58:22 +0200 Subject: [PATCH 27/43] [IMP] error messages for datetime error bzr revid: xmo@openerp.com-20121004115822-54mzaeqi05h0p4ve --- openerp/addons/base/ir/ir_fields.py | 6 ++++-- openerp/tests/addons/test_impex/tests/test_load.py | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 65d4a786178..05aa6782e9c 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -127,7 +127,8 @@ class ir_fields_converter(orm.Model): return value, [] except ValueError: raise ValueError( - _(u"'%s' does not seem to be a valid date for field '%%(field)s'") % value) + _(u"'%s' does not seem to be a valid date for field '%%(field)s'. Use the format '%s'") % + (value, u"2012-12-31")) def _str_to_datetime(self, cr, uid, model, column, value, context=None): try: @@ -135,7 +136,8 @@ class ir_fields_converter(orm.Model): return value, [] except ValueError: raise ValueError( - _(u"'%s' does not seem to be a valid datetime for field '%%(field)s'") % value) + _(u"'%s' does not seem to be a valid datetime for field '%%(field)s'. Use the format '%s'") % + (value, u"2012-12-31 23:59:59")) def _get_translations(self, cr, uid, types, src, context): types = tuple(types) diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 65c4b6e9e57..8bfcd20f4f3 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -1008,7 +1008,7 @@ class test_date(ImporterCase): result = self.import_(['value'], [['not really a date']]) self.assertEqual(result['messages'], [ message(u"'not really a date' does not seem to be a valid date " - u"for field 'unknown'")]) + u"for field 'unknown'. Use the format '2012-12-31'")]) self.assertIs(result['ids'], False) class test_datetime(ImporterCase): @@ -1028,7 +1028,8 @@ class test_datetime(ImporterCase): result = self.import_(['value'], [['not really a datetime']]) self.assertEqual(result['messages'], [ message(u"'not really a datetime' does not seem to be a valid " - u"datetime for field 'unknown'")]) + u"datetime for field 'unknown'. Use the format " + u"'2012-12-31 23:59:59'")]) self.assertIs(result['ids'], False) # function, related, reference: written to db as-is... From 67a9cf851cb5405aa97c26284757018cf1f1ade4 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 4 Oct 2012 14:32:37 +0200 Subject: [PATCH 28/43] [IMP] use moreinfo key to provide more info as to what the boolean and date value should look like bzr revid: xmo@openerp.com-20121004123237-p5ht1uozaobxn8b7 --- openerp/addons/base/ir/ir_fields.py | 14 +++++++++----- openerp/tests/addons/test_impex/tests/test_load.py | 8 +++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 05aa6782e9c..d949c00c420 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -99,7 +99,9 @@ class ir_fields_converter(orm.Model): return True, [orm.ImportWarning( _(u"Unknown value '%s' for boolean field '%%(field)s', assuming '%s'") - % (value, yes))] + % (value, yes), { + 'moreinfo': _(u"Use '1' for yes and '0' for no") + })] def _str_to_integer(self, cr, uid, model, column, value, context=None): try: @@ -127,8 +129,9 @@ class ir_fields_converter(orm.Model): return value, [] except ValueError: raise ValueError( - _(u"'%s' does not seem to be a valid date for field '%%(field)s'. Use the format '%s'") % - (value, u"2012-12-31")) + _(u"'%s' does not seem to be a valid date for field '%%(field)s'") % value, { + 'moreinfo': _(u"Use the format '%s'") % u"2012-12-31" + }) def _str_to_datetime(self, cr, uid, model, column, value, context=None): try: @@ -136,8 +139,9 @@ class ir_fields_converter(orm.Model): return value, [] except ValueError: raise ValueError( - _(u"'%s' does not seem to be a valid datetime for field '%%(field)s'. Use the format '%s'") % - (value, u"2012-12-31 23:59:59")) + _(u"'%s' does not seem to be a valid datetime for field '%%(field)s'") % value, { + 'moreinfo': _(u"Use the format '%s'") % u"2012-12-31 23:59:59" + }) def _get_translations(self, cr, uid, types, src, context): types = tuple(types) diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 8bfcd20f4f3..547675da2e4 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -184,6 +184,7 @@ class test_boolean_field(ImporterCase): self.assertEqual(len(result['ids']), 10) self.assertEqual(result['messages'], [ message(u"Unknown value '%s' for boolean field 'unknown', assuming 'yes'" % v[0], + moreinfo=u"Use '1' for yes and '0' for no", type='warning', from_=i, to_=i, record=i) for i, v in enumerate(trues) if v[0] not in ('true', 'yes', '1') @@ -1008,7 +1009,8 @@ class test_date(ImporterCase): result = self.import_(['value'], [['not really a date']]) self.assertEqual(result['messages'], [ message(u"'not really a date' does not seem to be a valid date " - u"for field 'unknown'. Use the format '2012-12-31'")]) + u"for field 'unknown'", + moreinfo=u"Use the format '2012-12-31'")]) self.assertIs(result['ids'], False) class test_datetime(ImporterCase): @@ -1028,8 +1030,8 @@ class test_datetime(ImporterCase): result = self.import_(['value'], [['not really a datetime']]) self.assertEqual(result['messages'], [ message(u"'not really a datetime' does not seem to be a valid " - u"datetime for field 'unknown'. Use the format " - u"'2012-12-31 23:59:59'")]) + u"datetime for field 'unknown'", + moreinfo=u"Use the format '2012-12-31 23:59:59'")]) self.assertIs(result['ids'], False) # function, related, reference: written to db as-is... From 27f15d8af855baaf8d01d69615256804a61320fd Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 4 Oct 2012 17:17:53 +0200 Subject: [PATCH 29/43] [IMP] 'more info' actions from relational fields conversion link to ir.model.data if failing to link with database or external ids bzr revid: xmo@openerp.com-20121004151753-wuyra1fjo31ur56g --- openerp/addons/base/ir/ir.xml | 1 + openerp/addons/base/ir/ir_fields.py | 22 +++++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/openerp/addons/base/ir/ir.xml b/openerp/addons/base/ir/ir.xml index ea549c39993..491bc9c8131 100644 --- a/openerp/addons/base/ir/ir.xml +++ b/openerp/addons/base/ir/ir.xml @@ -1019,6 +1019,7 @@ + diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index d949c00c420..2f557c4aaa9 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -201,13 +201,16 @@ class ir_fields_converter(orm.Model): if context is None: context = {} id = None warnings = [] - extras = {'moreinfo': { - 'type': 'ir.actions.act_window', 'target': 'new', - 'res_model': column._obj, 'view_mode': 'tree,form', - 'view_type': 'form', - 'views': [(False, 'tree', (False, 'form'))], - 'help': _(u"See all possible values") - }} + action = {'type': 'ir.actions.act_window', 'target': 'new', + 'view_mode': 'tree,form', 'view_type': 'form', + 'views': [(False, 'tree', (False, 'form'))], + 'help': _(u"See all possible values")} + if subfield is None: + action['res_model'] = column._obj + elif subfield in ('id', '.id'): + action['res_model'] = 'ir.model.data' + action['domain'] = [('model', '=', column._obj)] + RelatedModel = self.pool[column._obj] if subfield == '.id': field_type = _(u"database id") @@ -221,7 +224,7 @@ class ir_fields_converter(orm.Model): # type error raise ValueError( _(u"Invalid database id '%s' for the field '%%(field)s'") % value, - extras) + {'moreinfo': action}) elif subfield == 'id': field_type = _(u"external id") if '.' in value: @@ -252,7 +255,8 @@ class ir_fields_converter(orm.Model): if id is None: raise ValueError( _(u"No matching record found for %(field_type)s '%(value)s' in field '%%(field)s'") - % {'field_type': field_type, 'value': value}, extras) + % {'field_type': field_type, 'value': value}, + {'moreinfo': action}) return id, field_type, warnings def _referencing_subfield(self, record): From 1a54bd9e4bb4bd91e2049417a656a33907b18a55 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 4 Oct 2012 17:29:36 +0200 Subject: [PATCH 30/43] [FIX] import tests pertaining to failing to link a relational subfield by id, xid or name bzr revid: xmo@openerp.com-20121004152936-wsbwa4wbw9rii1an --- .../addons/test_impex/tests/test_load.py | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 547675da2e4..afa3e8dd8e3 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -12,16 +12,14 @@ def message(msg, type='error', from_=0, to_=0, record=0, field='value', **kwargs return dict(kwargs, type=type, rows={'from': from_, 'to': to_}, record=record, field=field, message=msg) -def moreaction(model): - return { - 'type': 'ir.actions.act_window', - 'target': 'new', - 'res_model': model, - 'view_mode': 'tree,form', - 'view_type': 'form', - 'views': [(False, 'tree', (False, 'form'))], - 'help': u"See all possible values" - } +def moreaction(**kwargs): + return dict(kwargs, + type='ir.actions.act_window', + target='new', + view_mode='tree,form', + view_type='form', + views=[(False, 'tree', (False, 'form'))], + help=u"See all possible values") def values(seq, field='value'): return [item[field] for item in seq] @@ -626,7 +624,7 @@ class test_m2o(ImporterCase): self.assertEqual(result['messages'], [ message(u"No matching record found for name '%s' in field 'unknown'" % id, from_=index, to_=index, record=index, - moreinfo=moreaction('export.integer')) + moreinfo=moreaction(res_model='export.integer')) for index, id in enumerate([integer_id1, integer_id2, integer_id1])]) self.assertIs(result['ids'], False) @@ -636,7 +634,8 @@ class test_m2o(ImporterCase): self.assertEqual(result['messages'], [ message(u"Invalid database id 'foo' for the field 'unknown'", - moreinfo=moreaction('export.integer')) + moreinfo=moreaction(res_model='ir.model.data', + domain=[('model','=','export.integer')])) ]) self.assertIs(result['ids'], False) @@ -654,19 +653,22 @@ class test_m2o(ImporterCase): result = self.import_(['value'], [['nameisnoexist:3']]) self.assertEqual(result['messages'], [message( u"No matching record found for name 'nameisnoexist:3' " - u"in field 'unknown'", moreinfo=moreaction('export.integer'))]) + u"in field 'unknown'", moreinfo=moreaction( + res_model='export.integer'))]) self.assertIs(result['ids'], False) result = self.import_(['value/id'], [['noxidhere']]) self.assertEqual(result['messages'], [message( u"No matching record found for external id 'noxidhere' " - u"in field 'unknown'", moreinfo=moreaction('export.integer'))]) + u"in field 'unknown'", moreinfo=moreaction( + res_model='ir.model.data', domain=[('model','=','export.integer')]))]) self.assertIs(result['ids'], False) result = self.import_(['value/.id'], [['66']]) self.assertEqual(result['messages'], [message( u"No matching record found for database id '66' " - u"in field 'unknown'", moreinfo=moreaction('export.integer'))]) + u"in field 'unknown'", moreinfo=moreaction( + res_model='ir.model.data', domain=[('model','=','export.integer')]))]) self.assertIs(result['ids'], False) def test_fail_multiple(self): @@ -718,7 +720,8 @@ class test_m2m(ImporterCase): result = self.import_(['value/.id'], [['42']]) self.assertEqual(result['messages'], [message( u"No matching record found for database id '42' in field " - u"'unknown'", moreinfo=moreaction('export.many2many.other'))]) + u"'unknown'", moreinfo=moreaction( + res_model='ir.model.data', domain=[('model','=','export.many2many.other')]))]) self.assertIs(result['ids'], False) def test_xids(self): @@ -744,7 +747,8 @@ class test_m2m(ImporterCase): result = self.import_(['value/id'], [['noxidforthat']]) self.assertEqual(result['messages'], [message( u"No matching record found for external id 'noxidforthat' in field" - u" 'unknown'", moreinfo=moreaction('export.many2many.other'))]) + u" 'unknown'", moreinfo=moreaction( + res_model='ir.model.data', domain=[('model','=','export.many2many.other')]))]) self.assertIs(result['ids'], False) def test_names(self): @@ -773,7 +777,8 @@ class test_m2m(ImporterCase): result = self.import_(['value'], [['wherethem2mhavenonames']]) self.assertEqual(result['messages'], [message( u"No matching record found for name 'wherethem2mhavenonames' in " - u"field 'unknown'", moreinfo=moreaction('export.many2many.other'))]) + u"field 'unknown'", moreinfo=moreaction( + res_model='export.many2many.other'))]) self.assertIs(result['ids'], False) def test_import_to_existing(self): @@ -807,7 +812,7 @@ class test_o2m(ImporterCase): [['5', s]]) self.assertEqual(result['messages'], [message( u"No matching record found for name '%s' in field 'unknown'" % s, - moreinfo=moreaction('export.one2many.child'))]) + moreinfo=moreaction(res_model='export.one2many.child'))]) self.assertIs(result['ids'], False) def test_single(self): From 350d365ba087288b36bf55a6097bc4738115719c Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 8 Oct 2012 15:14:07 +0200 Subject: [PATCH 31/43] [IMP] remove docstring for cr, uid following odo review bzr revid: xmo@openerp.com-20121008131407-5hnxkn17lycfoqzu --- openerp/addons/base/ir/ir_fields.py | 4 ---- openerp/osv/orm.py | 5 ----- 2 files changed, 9 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 2f557c4aaa9..401808dfc30 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -61,8 +61,6 @@ class ir_fields_converter(orm.Model): it returns. The handling of a warning at the upper levels is the same as ``ValueError`` above. - :param cr: openerp cursor - :param uid: ID of user calling the converter :param column: column object to generate a value for :type column: :class:`fields._column` :param type fromtype: type to convert to something fitting for ``column`` @@ -183,8 +181,6 @@ class ir_fields_converter(orm.Model): """ Finds a database id for the reference ``value`` in the referencing subfield ``subfield`` of the provided column of the provided model. - :param cr: OpenERP cursor - :param uid: OpenERP user id :param model: model to which the column belongs :param column: relational column for which references are provided :param subfield: a relational subfield allowing building of refs to diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index b0b0fad88fa..a0188c29e51 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1310,8 +1310,6 @@ class BaseModel(object): the same order they were extracted from the file. They can be passed directly to :meth:`~read` - :param cr: cursor for the request - :param int uid: ID of the user attempting the data import :param fields: list of fields to import, at the same index as the corresponding data :type fields: list(str) :param data: row-major matrix of data to import @@ -1377,8 +1375,6 @@ class BaseModel(object): * None is the name_get for the record (to use with name_create/name_search) * "id" is the External ID for the record * ".id" is the Database ID for the record - - :param ImportLogger logger: """ columns = dict((k, v.column) for k, v in self._all_columns.iteritems()) # Fake columns to avoid special cases in extractor @@ -1448,7 +1444,6 @@ class BaseModel(object): strings) into forms which can be written to the database (via self.create or (ir.model.data)._update) - :param ImportLogger parent_logger: :returns: a list of triplets of (id, xid, record) :rtype: list((int|None, str|None, dict)) """ From 53786034e3d38b8b187c6ea537af98b869872648 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 8 Oct 2012 15:15:45 +0200 Subject: [PATCH 32/43] [FIX] docstring typo for name of converter methods on ir_fields, following odo review bzr revid: xmo@openerp.com-20121008131545-u5sdlolmmgr8db2y --- openerp/addons/base/ir/ir_fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 401808dfc30..d20841df9a7 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -39,7 +39,7 @@ class ir_fields_converter(orm.Model): value acceptable for a write() on the column ``column``. By default, tries to get a method on itself with a name matching the - pattern ``_$fromtype_$column._type`` and returns it. + pattern ``_$fromtype_to_$column._type`` and returns it. Converter callables can either return a value and a list of warnings to their caller or raise ``ValueError``, which will be interpreted as a From 00c7d9f4c02d8ca79ccd7f42c954f09367837275 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 8 Oct 2012 15:20:54 +0200 Subject: [PATCH 33/43] [FIX] incorrect views spec in relational fields 'more info' action, spotted by odo bzr revid: xmo@openerp.com-20121008132054-y57mg2rhxg6hkx2s --- openerp/addons/base/ir/ir_fields.py | 2 +- openerp/tests/addons/test_impex/tests/test_load.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index d20841df9a7..e6d9c46fbb8 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -199,7 +199,7 @@ class ir_fields_converter(orm.Model): warnings = [] action = {'type': 'ir.actions.act_window', 'target': 'new', 'view_mode': 'tree,form', 'view_type': 'form', - 'views': [(False, 'tree', (False, 'form'))], + 'views': [(False, 'tree'), (False, 'form')], 'help': _(u"See all possible values")} if subfield is None: action['res_model'] = column._obj diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index afa3e8dd8e3..18958969dfb 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -18,7 +18,7 @@ def moreaction(**kwargs): target='new', view_mode='tree,form', view_type='form', - views=[(False, 'tree', (False, 'form'))], + views=[(False, 'tree'), (False, 'form')], help=u"See all possible values") def values(seq, field='value'): From 12f4150195c057c68d96c1430333b56d6ca7d903 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 8 Oct 2012 15:30:06 +0200 Subject: [PATCH 34/43] [IMP] avoid reimplementing (ir.model.data).get_object_reference() by hand, caught by odo bzr revid: xmo@openerp.com-20121008133006-zsjdvax07zsrjwm6 --- openerp/addons/base/ir/ir_fields.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index e6d9c46fbb8..3e63228210c 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -229,11 +229,8 @@ class ir_fields_converter(orm.Model): module, xid = context.get('_import_current_module', ''), value ModelData = self.pool['ir.model.data'] try: - md_id = ModelData._get_id(cr, uid, module, xid) - model_data = ModelData.read(cr, uid, [md_id], ['res_id'], - context=context) - if model_data: - id = model_data[0]['res_id'] + _model, id = ModelData.get_object_reference( + cr, uid, module, xid) except ValueError: pass # leave id is None elif subfield is None: field_type = _(u"name") From 443b5b9767cc35e3ab58b1283cacf38b9757c146 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 8 Oct 2012 15:31:16 +0200 Subject: [PATCH 35/43] [FIX] missing translation mark on exception message bzr revid: xmo@openerp.com-20121008133116-da2n77kbk9evaa7g --- openerp/addons/base/ir/ir_fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 3e63228210c..3d9276fde2f 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -243,7 +243,7 @@ class ir_fields_converter(orm.Model): % (len(ids)))) id, _name = ids[0] else: - raise Exception(u"Unknown sub-field '%s'" % subfield) + raise Exception(_(u"Unknown sub-field '%s'") % subfield) if id is None: raise ValueError( From afee5b63eeb7c913cd2fa0b5efa4c869c1af4651 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 8 Oct 2012 15:33:11 +0200 Subject: [PATCH 36/43] [IMP] use readable relation helper to generate a '6' m2m command bzr revid: xmo@openerp.com-20121008133311-ejaix536syks41oo --- openerp/addons/base/ir/ir_fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 3d9276fde2f..e714e6c11f9 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -297,7 +297,7 @@ class ir_fields_converter(orm.Model): cr, uid, model, column, subfield, reference, context=context) ids.append(id) warnings.extend(ws) - return [(6, 0, ids)], warnings + return REPLACE_WITH(ids), warnings def _str_to_one2many(self, cr, uid, model, column, records, context=None): commands = [] From e8af13f0e88024230007c2345268946352546565 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 8 Oct 2012 15:38:23 +0200 Subject: [PATCH 37/43] [IMP] don't pass in a default lang to _get_source if no value is provided in the context, caught by odo bzr revid: xmo@openerp.com-20121008133823-vhypgddiicztai3a --- openerp/osv/orm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index a0188c29e51..e890f6ebbe8 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1453,7 +1453,7 @@ class BaseModel(object): Translation = self.pool['ir.translation'] field_names = dict( (f, (Translation._get_source(cr, uid, self._name + ',' + f, 'field', - context.get('lang', False) or 'en_US') + context.get('lang')) or column.string or f)) for f, column in columns.iteritems()) converters = dict( From 35c0e73eb4e5bc6b169b643b46ebcb899b0f9468 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 9 Oct 2012 09:09:34 +0200 Subject: [PATCH 38/43] [FIX] m2m command in import command helpers create a single command, still need the list around commands and the tests would have caught it, if I'd run them... bzr revid: xmo@openerp.com-20121009070934-7i1hxuzumdu8x1jy --- openerp/addons/base/ir/ir_fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index e714e6c11f9..1c14fdb3c4d 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -297,7 +297,7 @@ class ir_fields_converter(orm.Model): cr, uid, model, column, subfield, reference, context=context) ids.append(id) warnings.extend(ws) - return REPLACE_WITH(ids), warnings + return [REPLACE_WITH(ids)], warnings def _str_to_one2many(self, cr, uid, model, column, records, context=None): commands = [] From 10345423b16f2a16c43fda671024e6f793eb7c1b Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 9 Oct 2012 10:54:46 +0200 Subject: [PATCH 39/43] [FIX] treat imported datetime as local to the context-provided or user's datetime bzr revid: xmo@openerp.com-20121009085446-obfpn4c5r250zonc --- openerp/addons/base/ir/ir_fields.py | 38 +++++++++++-- .../addons/test_impex/tests/test_load.py | 53 ++++++++++++++++++- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/openerp/addons/base/ir/ir_fields.py b/openerp/addons/base/ir/ir_fields.py index 1c14fdb3c4d..94a0be9e86c 100644 --- a/openerp/addons/base/ir/ir_fields.py +++ b/openerp/addons/base/ir/ir_fields.py @@ -1,10 +1,14 @@ # -*- coding: utf-8 -*- +import datetime import functools import operator import itertools -import psycopg2 import time -from openerp.osv import orm, fields + +import psycopg2 +import pytz + +from openerp.osv import orm from openerp.tools.translate import _ from openerp.tools.misc import DEFAULT_SERVER_DATE_FORMAT,\ DEFAULT_SERVER_DATETIME_FORMAT @@ -131,16 +135,42 @@ class ir_fields_converter(orm.Model): 'moreinfo': _(u"Use the format '%s'") % u"2012-12-31" }) + def _input_tz(self, cr, uid, context): + # if there's a tz in context, try to use that + if context.get('tz'): + try: + return pytz.timezone(context['tz']) + except pytz.UnknownTimeZoneError: + pass + + # if the current user has a tz set, try to use that + user = self.pool['res.users'].read( + cr, uid, [uid], ['tz'], context=context)[0] + if user['tz']: + try: + return pytz.timezone(user['tz']) + except pytz.UnknownTimeZoneError: + pass + + # fallback if no tz in context or on user: UTC + return pytz.UTC + def _str_to_datetime(self, cr, uid, model, column, value, context=None): + if context is None: context = {} try: - time.strptime(value, DEFAULT_SERVER_DATETIME_FORMAT) - return value, [] + parsed_value = datetime.datetime.strptime( + value, DEFAULT_SERVER_DATETIME_FORMAT) except ValueError: raise ValueError( _(u"'%s' does not seem to be a valid datetime for field '%%(field)s'") % value, { 'moreinfo': _(u"Use the format '%s'") % u"2012-12-31 23:59:59" }) + input_tz = self._input_tz(cr, uid, context)# Apply input tz to the parsed naive datetime + dt = input_tz.localize(parsed_value, is_dst=False) + # And convert to UTC before reformatting for writing + return dt.astimezone(pytz.UTC).strftime(DEFAULT_SERVER_DATETIME_FORMAT), [] + def _get_translations(self, cr, uid, types, src, context): types = tuple(types) # Cache translations so they don't have to be reloaded from scratch on diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 18958969dfb..043337dd027 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -1039,5 +1039,54 @@ class test_datetime(ImporterCase): moreinfo=u"Use the format '2012-12-31 23:59:59'")]) self.assertIs(result['ids'], False) -# function, related, reference: written to db as-is... -# => function uses @type for value coercion/conversion + def test_checktz1(self): + """ Imported date should be interpreted as being in the tz provided by + the context + """ + # write dummy tz in user (Asia/Hovd UTC+0700), should be superseded by + # context + self.registry('res.users').write( + self.cr, openerp.SUPERUSER_ID, [openerp.SUPERUSER_ID], + {'tz': 'Asia/Hovd'}) + + # UTC+1400 + result = self.import_( + ['value'], [['2012-02-03 11:11:11']], {'tz': 'Pacific/Kiritimati'}) + self.assertFalse(result['messages']) + self.assertEqual( + values(self.read(domain=[('id', 'in', result['ids'])])), + ['2012-02-02 21:11:11']) + + # UTC-0930 + result = self.import_( + ['value'], [['2012-02-03 11:11:11']], {'tz': 'Pacific/Marquesas'}) + self.assertFalse(result['messages']) + self.assertEqual( + values(self.read(domain=[('id', 'in', result['ids'])])), + ['2012-02-03 20:41:11']) + + def test_usertz(self): + """ If the context does not hold a timezone, the importing user's tz + should be used + """ + # UTC +1000 + self.registry('res.users').write( + self.cr, openerp.SUPERUSER_ID, [openerp.SUPERUSER_ID], + {'tz': 'Asia/Yakutsk'}) + + result = self.import_( + ['value'], [['2012-02-03 11:11:11']]) + self.assertFalse(result['messages']) + self.assertEqual( + values(self.read(domain=[('id', '=', result['ids'])])), + ['2012-02-03 01:11:11']) + + def test_notz(self): + """ If there is no tz either in the context or on the user, falls back + to UTC + """ + result = self.import_(['value'], [['2012-02-03 11:11:11']]) + self.assertFalse(result['messages']) + self.assertEqual( + values(self.read(domain=[('id', '=', result['ids'])])), + ['2012-02-03 11:11:11']) From 510eea5305ae80832f32d804464c841f6e52420e Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 9 Oct 2012 11:25:19 +0200 Subject: [PATCH 40/43] [CHG] make empty cells clear out the corresponding field on import, rather than skip them bzr revid: xmo@openerp.com-20121009092519-ge86yp3nnkqe9gic --- openerp/osv/orm.py | 1 + openerp/tests/addons/test_impex/models.py | 4 +++- .../tests/addons/test_impex/tests/test_load.py | 16 +++++++++++++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index e890f6ebbe8..4c84b611629 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -1496,6 +1496,7 @@ class BaseModel(object): for field, strvalue in record.iteritems(): if field in (None, 'id', '.id'): continue if not strvalue: + converted[field] = False continue # In warnings and error messages, use translated string as diff --git a/openerp/tests/addons/test_impex/models.py b/openerp/tests/addons/test_impex/models.py index 50340082d08..48bd9948a0f 100644 --- a/openerp/tests/addons/test_impex/models.py +++ b/openerp/tests/addons/test_impex/models.py @@ -128,8 +128,10 @@ class SelectionWithDefault(orm.Model): _name = 'export.selection.withdefault' _columns = { - 'value': fields.selection([(1, "Foo"), (2, "Bar")], required=True), + 'const': fields.integer(), + 'value': fields.selection([(1, "Foo"), (2, "Bar")]), } _defaults = { + 'const': 4, 'value': 2, } diff --git a/openerp/tests/addons/test_impex/tests/test_load.py b/openerp/tests/addons/test_impex/tests/test_load.py index 043337dd027..066fea62942 100644 --- a/openerp/tests/addons/test_impex/tests/test_load.py +++ b/openerp/tests/addons/test_impex/tests/test_load.py @@ -476,14 +476,24 @@ class test_selection(ImporterCase): class test_selection_with_default(ImporterCase): model_name = 'export.selection.withdefault' - def test_skip_empty(self): - """ Empty cells should be entirely skipped so that default values can - be inserted by the ORM + def test_empty(self): + """ Empty cells should set corresponding field to False """ result = self.import_(['value'], [['']]) self.assertFalse(result['messages']) self.assertEqual(len(result['ids']), 1) + self.assertEqual( + values(self.read()), + [False]) + + def test_default(self): + """ Non-provided cells should set corresponding field to default + """ + result = self.import_(['const'], [['42']]) + self.assertFalse(result['messages']) + self.assertEqual(len(result['ids']), 1) + self.assertEqual( values(self.read()), [2]) From 61106da6e82626e2507aca424c3dded6760077d1 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 9 Oct 2012 12:03:26 +0200 Subject: [PATCH 41/43] [FIX] import docs: clarify handling of empty cells bzr revid: xmo@openerp.com-20121009100326-bil1rw8904ze7opk --- doc/import.rst | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/doc/import.rst b/doc/import.rst index f3db5d50bcf..bc654170659 100644 --- a/doc/import.rst +++ b/doc/import.rst @@ -78,10 +78,17 @@ Conversion This second phase takes the record dicts, extracts the :ref:`dbid` and :ref:`xid` if present and attempts to convert each field to a type -matching what OpenERP expects to write. Empty fields (empty strings) -are replaced with the ``False`` value, and non-empty fields are -converted through -:class:`~openerp.addons.base.ir.ir_fields.ir_fields_converter`: +matching what OpenERP expects to write. + +* Empty fields (empty strings) are replaced with the ``False`` value + +* Non-empty fields are converted through + :class:`~openerp.addons.base.ir.ir_fields.ir_fields_converter` + +.. note:: if a field is specified in the import, its default will *never* be + used. If some records need to have a value and others need to use + the model's default, either specify that default explicitly or do + the import in two phases. Char, text and binary fields ++++++++++++++++++++++++++++ From e9aef291108da25a883042676f801b9650bd300d Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 9 Oct 2012 12:06:54 +0200 Subject: [PATCH 42/43] [FIX] titles of import doc bzr revid: xmo@openerp.com-20121009100654-iqhppxeo9fijnceb --- doc/import.rst | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/doc/import.rst b/doc/import.rst index bc654170659..44e99e88d3e 100644 --- a/doc/import.rst +++ b/doc/import.rst @@ -22,7 +22,7 @@ This document attempts to explain the behavior and limitations of :meth:`~openerp.osv.orm.BaseModel.load`. Data -==== +---- The input ``data`` is a regular row-major matrix of strings (in Python datatype terms, a ``list`` of rows, each row being a ``list`` of @@ -38,7 +38,7 @@ and each cell of a row yields a value for the corresponding field of the row's record. There is currently one exception for this rule: One to Many fields ------------------- +++++++++++++++++++ Because O2M fields contain multiple records "embedded" in the main one, and these sub-records are fully dependent on the main record (are @@ -53,14 +53,14 @@ fields. During parsing, they are extracted into their own ``data`` matrix for the o2m field they correspond to. Import process -============== +-------------- Here are the phases of import. Note that the concept of "phases" is fuzzy as it's currently more of a pipeline, each record moves through the entire pipeline before the next one is processed. Extraction ----------- +++++++++++ The first phase of the import is the extraction of the current row (and potentially a section of rows following it if it has One to Many @@ -74,7 +74,7 @@ This phase also generates the ``rows`` indexes for any :ref:`import-message` produced thereafter. Conversion ----------- +++++++++++ This second phase takes the record dicts, extracts the :ref:`dbid` and :ref:`xid` if present and attempts to convert each field to a type @@ -91,12 +91,12 @@ matching what OpenERP expects to write. the import in two phases. Char, text and binary fields -++++++++++++++++++++++++++++ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Are returned as-is, without any alteration. Boolean fields -++++++++++++++ +~~~~~~~~~~~~~~ The string value is compared (in a case-insensitive manner) to ``0``, ``false`` and ``no`` as well of any translation thereof loaded in the @@ -109,14 +109,14 @@ any translation of these in the database). The field is always set to also be output. Integers and float fields -+++++++++++++++++++++++++ +~~~~~~~~~~~~~~~~~~~~~~~~~ The field is parsed with Python's built-in conversion routines (``int`` and ``float`` respectively), if the conversion fails an error is generated. Selection fields -++++++++++++++++ +~~~~~~~~~~~~~~~~ The field is compared to 1. the values of the selection (first part of each selection tuple) and 2. all translations of the selection label @@ -131,7 +131,7 @@ The same process applies to both list-type and function-type selection fields. Many to One field -+++++++++++++++++ +~~~~~~~~~~~~~~~~~ If the specified field is the relational field itself (``m2o``), the value is used in a ``name_search``. The first record returned by @@ -151,14 +151,14 @@ the same as for external ids (on database identifiers instead of external ones). Many to Many field -++++++++++++++++++ +~~~~~~~~~~~~~~~~~~ The field's value is interpreted as a comma-separated list of names, external ids or database ids. For each one, the process previously used for the many to one field is applied. One to Many field -+++++++++++++++++ +~~~~~~~~~~~~~~~~~ For each o2m record extracted, if the record has a ``name``, :ref:`xid` or :ref:`dbid` the :ref:`dbid` is looked up and checked @@ -170,13 +170,13 @@ an UPDATE with the non-db values for the relational field. Otherwise a CREATE command is emmitted. Create/Write ------------- +++++++++++++ If the conversion was successful, the converted record is then saved to the database via ``(ir.model.data)._update``. Error handling --------------- +++++++++++++++ The import process will only catch 2 types of exceptions to convert them to error messages: ``ValueError`` during the conversion process, @@ -199,7 +199,7 @@ The import process uses savepoint to: .. _import-messages: Messages -======== +-------- A message is a dictionary with 5 mandatory keys and one optional key: From 96ffc4401a18c1d4b798c01970d0f3b40258a206 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 9 Oct 2012 12:10:51 +0200 Subject: [PATCH 43/43] [IMP] document date and datetime conversions bzr revid: xmo@openerp.com-20121009101051-rsdq3u3ues022wuc --- doc/import.rst | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/doc/import.rst b/doc/import.rst index 44e99e88d3e..bffb1af334b 100644 --- a/doc/import.rst +++ b/doc/import.rst @@ -169,6 +169,31 @@ an UPDATE with the non-db values for the relational field. Otherwise a CREATE command is emmitted. +Date fields +~~~~~~~~~~~ + +The value's format is checked against +:data:`~openerp.tools.misc.DEFAULT_SERVER_DATE_FORMAT`, an error is +generated if it does not match the specified format. + +Datetime fields +~~~~~~~~~~~~~~~ + +The value's format is checked against +:data:`~openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT`, an error +is generated if it does not match. + +The value is then interpreted as a datetime in the user's +timezone. The timezone is specified thus: + +* If the import ``context`` contains a ``tz`` key with a valid + timezone name, this is the timezone of the datetime. + +* Otherwise if the user performing the import has a ``tz`` attribute + set to a valid timezone name, this is the timezone of the datetime. + +* Otherwise interpret the datetime as being in the ``UTC`` timezone. + Create/Write ++++++++++++