From b34767381b64dc5ec4f6b87b23b1869ae6938fce Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Fri, 10 Aug 2012 09:35:13 +0200 Subject: [PATCH 01/25] [ADD] fields fetching and filtering method for import bzr revid: xmo@openerp.com-20120810073513-zidmkuw6yjhtuwpj --- addons/base_import/__init__.py | 2 + addons/base_import/__openerp__.py | 30 ++++++ addons/base_import/models.py | 127 +++++++++++++++++++++++++ addons/base_import/tests/__init__.py | 3 + addons/base_import/tests/models.py | 92 ++++++++++++++++++ addons/base_import/tests/test_cases.py | 73 ++++++++++++++ 6 files changed, 327 insertions(+) create mode 100644 addons/base_import/__init__.py create mode 100644 addons/base_import/__openerp__.py create mode 100644 addons/base_import/models.py create mode 100644 addons/base_import/tests/__init__.py create mode 100644 addons/base_import/tests/models.py create mode 100644 addons/base_import/tests/test_cases.py diff --git a/addons/base_import/__init__.py b/addons/base_import/__init__.py new file mode 100644 index 00000000000..f0736de3bd1 --- /dev/null +++ b/addons/base_import/__init__.py @@ -0,0 +1,2 @@ +import models +import tests.models diff --git a/addons/base_import/__openerp__.py b/addons/base_import/__openerp__.py new file mode 100644 index 00000000000..7f117349ba9 --- /dev/null +++ b/addons/base_import/__openerp__.py @@ -0,0 +1,30 @@ +{ + 'name': 'Base import', + 'description': """ +New extensible file import for OpenERP +====================================== + +Re-implement openerp's file import system: + +* Server side, the previous system forces most of the logic into the + client which duplicates the effort (between clients), makes the + import system much harder to use without a client (direct RPC or + other forms of automation) and makes knowledge about the + import/export system much harder to gather as it is spread over + 3+ different projects. + +* In a more extensible manner, so users and partners can build their + own front-end to import from other file formats (e.g. OpenDocument + files) which may be simpler to handle in their work flow or from + their data production sources. + +* In a module, so that administrators and users of OpenERP who do not + need or want an online import can avoid it being available to users. +""", + 'category': 'Uncategorized', + 'website': 'http://www.openerp.com', + 'author': 'OpenERP SA', + 'depends': ['base'], + 'installable': True, + 'auto_install': False, # set to true and allow uninstall? +} diff --git a/addons/base_import/models.py b/addons/base_import/models.py new file mode 100644 index 00000000000..a5e2678cd73 --- /dev/null +++ b/addons/base_import/models.py @@ -0,0 +1,127 @@ +import csv +import itertools + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +from openerp.osv import orm, fields +from openerp.tools.translate import _ + +FIELDS_RECURSION_LIMIT = 2 +class ir_import(orm.TransientModel): + _name = 'base_import.import' + + _columns = { + 'res_model': fields.char('Model', size=64), + 'file': fields.binary('File'), + 'file_name': fields.char('File Name', size=None), + } + + def get_fields(self, cr, uid, model, context=None, + depth=FIELDS_RECURSION_LIMIT): + """ Recursively get fields for the provided model (through + fields_get) and filter them according to importability + + The output format is a list of ``Field``, with ``Field`` + defined as: + + .. class:: Field + + .. attribute:: id (str) + + A non-unique identifier for the field, used to compute + the span of the ``required`` attribute: if multiple + ``required`` fields have the same id, only one of them + is necessary. + + .. attribute:: name (str) + + The field's logical (OpenERP) name within the scope of + its parent. + + .. attribute:: string (str) + + The field's human-readable name (``@string``) + + .. attribute:: required (bool) + + Whether the field is marked as required in the + model. Clients must provide non-empty import values + for all required fields or the import will error out. + + .. attribute:: fields (list(Field)) + + The current field's subfields. The database and + external identifiers for m2o and m2m fields; a + filtered and transformed fields_get for o2m fields (to + a variable depth defined by ``depth``). + + Fields with no sub-fields will have an empty list of + sub-fields. + + :param str model: name of the model to get fields form + :param int landing: depth of recursion into o2m fields + """ + fields = [{ + 'id': 'id', + 'name': 'id', + 'string': _("External ID"), + 'required': False, + 'fields': [], + }] + fields_got = self.pool[model].fields_get(cr, uid, context=context) + for name, field in fields_got.iteritems(): + if field.get('readonly'): + states = field.get('states') + if not states: + continue + # states = {state: [(attr, value), (attr2, value2)], state2:...} + if not any(attr == 'readonly' and value is False + for attr, value in itertools.chain.from_iterable( + states.itervalues())): + continue + + f = { + 'id': name, + 'name': name, + 'string': field['string'], + # Y U NO ALWAYS HAVE REQUIRED + 'required': bool(field.get('required')), + 'fields': [], + } + + if field['type'] in ('many2many', 'many2one'): + f['fields'] = [ + dict(f, name='id', string=_("External ID")), + dict(f, name='.id', string=_("Database ID")), + ] + elif field['type'] == 'one2many' and depth: + f['fields'] = self.get_fields( + cr, uid, field['relation'], context=context, depth=depth-1) + + fields.append(f) + + return fields + + def parse_preview(self, cr, uid, id, options, count=10, context=None): + """ Generates a preview of the uploaded files, and performs + fields-matching between the import's file data and the model's + columns. + + :param id: identifier of the import + :param int count: number of preview lines to generate + :param dict options: format-specific options + :returns: (fields, matches, preview) + :rtype: (dict(str: dict(...)), dict(str, str), list(list(str))) + """ + record = self.browse(cr, uid, id, context=context) + # recursive fields_get (cache based on res_model?) + fields = record.get_fields(record.res_model) + import pprint + pprint.pprint(fields) + # extract title row? + # match title row to fields_ge + # Extract first $count rows + # return triplet diff --git a/addons/base_import/tests/__init__.py b/addons/base_import/tests/__init__.py new file mode 100644 index 00000000000..abef28c8ca1 --- /dev/null +++ b/addons/base_import/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_cases + +checks = [test_cases] diff --git a/addons/base_import/tests/models.py b/addons/base_import/tests/models.py new file mode 100644 index 00000000000..2eeea1543d8 --- /dev/null +++ b/addons/base_import/tests/models.py @@ -0,0 +1,92 @@ +from openerp.osv import orm, fields + +def name(n): return 'base_import.tests.models.%s' % n + +class char(orm.Model): + _name = name('char') + + _columns = { + 'value': fields.char('unknown', size=None) + } + +class char_required(orm.Model): + _name = name('char.required') + + _columns = { + 'value': fields.char('unknown', size=None, required=True) + } + +class char_readonly(orm.Model): + _name = name('char.readonly') + + _columns = { + 'value': fields.char('unknown', size=None, readonly=True) + } + +class char_states(orm.Model): + _name = name('char.states') + + _columns = { + 'value': fields.char('unknown', size=None, readonly=True, states={'draft': [('readonly', False)]}) + } + +class char_noreadonly(orm.Model): + _name = name('char.noreadonly') + + _columns = { + 'value': fields.char('unknown', size=None, readonly=True, states={'draft': [('invisible', True)]}) + } + +class char_stillreadonly(orm.Model): + _name = name('char.stillreadonly') + + _columns = { + 'value': fields.char('unknown', size=None, readonly=True, states={'draft': [('readonly', True)]}) + } + +# TODO: complex field (m2m, o2m, m2o) +class m2o(orm.Model): + _name = name('m2o') + + _columns = { + 'value': fields.many2one(name('m2o.related')) + } +class m2o_related(orm.Model): + _name = name('m2o.related') + + _columns = { + 'value': fields.integer() + } + _defaults = { + 'value': 42 + } + +class m2o_required(orm.Model): + _name = name('m2o.required') + + _columns = { + 'value': fields.many2one(name('m2o.required.related'), required=True) + } +class m2o_required_related(orm.Model): + _name = name('m2o.required.related') + + _columns = { + 'value': fields.integer() + } + _defaults = { + 'value': 42 + } + +class o2m(orm.Model): + _name = name('o2m') + + _columns = { + 'value': fields.one2many(name('o2m.child'), 'parent_id') + } +class o2m_child(orm.Model): + _name = name('o2m.child') + + _columns = { + 'parent_id': fields.many2one(name('o2m')), + 'value': fields.integer() + } diff --git a/addons/base_import/tests/test_cases.py b/addons/base_import/tests/test_cases.py new file mode 100644 index 00000000000..d415c348f30 --- /dev/null +++ b/addons/base_import/tests/test_cases.py @@ -0,0 +1,73 @@ +from openerp.tests.common import TransactionCase + +ID_FIELD = {'id': 'id', 'name': 'id', 'string': "External ID", 'required': False, 'fields': []} +def make_field(name='value', string='unknown', required=False, fields=[]): + return [ + ID_FIELD, + {'id': name, 'name': name, 'string': string, 'required': required, 'fields': fields}, + ] + +class test_basic_fields(TransactionCase): + def get_fields(self, field): + return self.registry('base_import.import')\ + .get_fields(self.cr, self.uid, 'base_import.tests.models.' + field) + + def test_base(self): + """ A basic field is not required """ + self.assertEqual(self.get_fields('char'), make_field()) + + def test_required(self): + """ Required fields should be flagged (so they can be fill-required) """ + self.assertEqual(self.get_fields('char.required'), make_field(required=True)) + + def test_readonly(self): + """ Readonly fields should be filtered out""" + self.assertEqual(self.get_fields('char.readonly'), [ID_FIELD]) + + def test_readonly_states(self): + """ Readonly fields with states should not be filtered out""" + self.assertEqual(self.get_fields('char.states'), make_field()) + + def test_readonly_states_noreadonly(self): + """ Readonly fields with states having nothing to do with + readonly should still be filtered out""" + self.assertEqual(self.get_fields('char.noreadonly'), [ID_FIELD]) + + def test_readonly_states_stillreadonly(self): + """ Readonly fields with readonly states leaving them readonly + always... filtered out""" + self.assertEqual(self.get_fields('char.stillreadonly'), [ID_FIELD]) + + def test_m2o(self): + """ M2O fields should allow import of themselves (name_get), + their id and their xid""" + self.assertEqual(self.get_fields('m2o'), make_field(fields=[ + {'id': 'value', 'name': 'id', 'string': 'External ID', 'required': False, 'fields': []}, + {'id': 'value', 'name': '.id', 'string': 'Database ID', 'required': False, 'fields': []}, + ])) + + def test_m2o_required(self): + """ If an m2o field is required, its three sub-fields are + required as well (the client has to handle that: requiredness + is id-based) + """ + self.assertEqual(self.get_fields('m2o.required'), make_field(required=True, fields=[ + {'id': 'value', 'name': 'id', 'string': 'External ID', 'required': True, 'fields': []}, + {'id': 'value', 'name': '.id', 'string': 'Database ID', 'required': True, 'fields': []}, + ])) + +class test_o2m(TransactionCase): + def get_fields(self, field): + return self.registry('base_import.import')\ + .get_fields(self.cr, self.uid, 'base_import.tests.models.' + field) + + def test_shallow(self): + self.assertEqual(self.get_fields('o2m'), make_field(fields=[ + {'id': 'id', 'name': 'id', 'string': 'External ID', 'required': False, 'fields': []}, + # FIXME: should reverse field be ignored? + {'id': 'parent_id', 'name': 'parent_id', 'string': 'unknown', 'required': False, 'fields': [ + {'id': 'parent_id', 'name': 'id', 'string': 'External ID', 'required': False, 'fields': []}, + {'id': 'parent_id', 'name': '.id', 'string': 'Database ID', 'required': False, 'fields': []}, + ]}, + {'id': 'value', 'name': 'value', 'string': 'unknown', 'required': False, 'fields': []}, + ])) From 45d5003dd6678c4ae115fef3a84d153d973c2b39 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 13 Aug 2012 15:42:19 +0200 Subject: [PATCH 02/25] [ADD] matching and parsing of CSV, preview production bzr revid: xmo@openerp.com-20120813134219-ijo7d2kwn82qs4tm --- addons/base_import/models.py | 128 +++++++++++++++++-- addons/base_import/tests/models.py | 9 ++ addons/base_import/tests/test_cases.py | 166 +++++++++++++++++++++++++ 3 files changed, 292 insertions(+), 11 deletions(-) diff --git a/addons/base_import/models.py b/addons/base_import/models.py index a5e2678cd73..db1050636ff 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -1,5 +1,8 @@ +import base64 +import codecs import csv import itertools +import logging try: from cStringIO import StringIO @@ -10,6 +13,8 @@ from openerp.osv import orm, fields from openerp.tools.translate import _ FIELDS_RECURSION_LIMIT = 2 +ERROR_PREVIEW_BYTES = 200 +_logger = logging.getLogger(__name__) class ir_import(orm.TransientModel): _name = 'base_import.import' @@ -103,8 +108,87 @@ class ir_import(orm.TransientModel): fields.append(f) + # TODO: cache on model? return fields + def _read_csv(self, record, options): + """ Returns a CSV-parsed iterator of all empty lines in the file + + :throws csv.Error: if an error is detected during CSV parsing + :throws UnicodeDecodeError: if ``options.encoding`` is incorrect + """ + csv_iterator = csv.reader( + StringIO(base64.b64decode(record.file)), + quotechar=options['quote'], + delimiter=options['separator']) + csv_nonempty = itertools.ifilter(None, csv_iterator) + # TODO: guess encoding? + encoding = options.get('encoding', 'utf-8') + return itertools.imap( + lambda row: [item.decode(encoding) for item in row], + csv_nonempty) + + def _match_header(self, header, fields, options): + """ Attempts to match a given header to a field of the + imported model. + + :param str header: header name from the CSV file + :param fields: + :param dict options: + :returns: an empty list if the header couldn't be matched, or + all the fields to traverse + :rtype: list(Field) + """ + for field in fields: + # FIXME: should match all translations & original + # TODO: use string distance (levenshtein? hamming?) + if header == field['name'] \ + or header.lower() == field['string'].lower(): + return [field] + + if '/' not in header: + return [] + + # relational field path + traversal = [] + subfields = fields + # Iteratively dive into fields tree + for section in header.split('/'): + # Strip section in case spaces are added around '/' for + # readability of paths + match = self._match_header(section.strip(), subfields, options) + # Any match failure, exit + if not match: return [] + # prep subfields for next iteration within match[0] + field = match[0] + subfields = field['fields'] + traversal.append(field) + return traversal + + def _match_headers(self, rows, fields, options): + """ Attempts to match the imported model's fields to the + titles of the parsed CSV file, if the file is supposed to have + headers. + + Will consume the first line of the ``rows`` iterator. + + Returns either None (no title) or a dict mapping cell indices + to key paths in the ``fields`` tree + + :param Iterator rows: + :param dict fields: + :param dict options: + :rtype: None | dict(int: list(str)) + """ + if not options.get('headers'): + return None + + headers = next(rows) + return dict( + (index, [field['name'] for field in self._match_header(header, fields, options)] or None) + for index, header in enumerate(headers) + ) + def parse_preview(self, cr, uid, id, options, count=10, context=None): """ Generates a preview of the uploaded files, and performs fields-matching between the import's file data and the model's @@ -112,16 +196,38 @@ class ir_import(orm.TransientModel): :param id: identifier of the import :param int count: number of preview lines to generate - :param dict options: format-specific options - :returns: (fields, matches, preview) - :rtype: (dict(str: dict(...)), dict(str, str), list(list(str))) + :param options: format-specific options. + CSV: {encoding, quote, separator, headers} + :type options: {str, str, str, bool} + :returns: {fields, matches, preview} | {error, preview} + :rtype: {dict(str: dict(...)), dict(int, list(str)), list(list(str))} | {str, str} """ record = self.browse(cr, uid, id, context=context) - # recursive fields_get (cache based on res_model?) - fields = record.get_fields(record.res_model) - import pprint - pprint.pprint(fields) - # extract title row? - # match title row to fields_ge - # Extract first $count rows - # return triplet + fields = self.get_fields(cr, uid, record.res_model, context=context) + + try: + rows = self._read_csv(record, options) + + match = self._match_headers(rows, fields, options) + # Match should have consumed the first row (iif headers), get + # the ``count`` next rows for preview + preview = itertools.islice(rows, count) + return { + 'fields': fields, + 'matches': match, + 'preview': list(preview), + } + except (TypeError, UnicodeDecodeError), e: + # Due to lazy generators, UnicodeDecodeError (for + # instance) may only be raised when serializing the + # preview to a list in the return. + _logger.debug("Error during CSV parsing preview", exc_info=True) + return { + 'error': _("Failed to parse CSV file: %s") % e, + # iso-8859-1 ensures decoding will always succeed, + # even if it yields non-printable characters. This is + # in case of UnicodeDecodeError (or csv.Error + # compounded with UnicodeDecodeError) + 'preview': base64.b64decode(record.file)[:ERROR_PREVIEW_BYTES]\ + .decode('iso-8859-1'), + } diff --git a/addons/base_import/tests/models.py b/addons/base_import/tests/models.py index 2eeea1543d8..0720ae11eda 100644 --- a/addons/base_import/tests/models.py +++ b/addons/base_import/tests/models.py @@ -90,3 +90,12 @@ class o2m_child(orm.Model): 'parent_id': fields.many2one(name('o2m')), 'value': fields.integer() } + +class preview_model(orm.Model): + _name = name('preview') + + _columns = { + 'name': fields.char('Name', size=None), + 'somevalue': fields.integer('Some Value', required=True), + 'othervalue': fields.integer('Other Variable'), + } diff --git a/addons/base_import/tests/test_cases.py b/addons/base_import/tests/test_cases.py index d415c348f30..b10749bb585 100644 --- a/addons/base_import/tests/test_cases.py +++ b/addons/base_import/tests/test_cases.py @@ -1,5 +1,9 @@ +# -*- encoding: utf-8 -*- +import unittest2 from openerp.tests.common import TransactionCase +from .. import models + ID_FIELD = {'id': 'id', 'name': 'id', 'string': "External ID", 'required': False, 'fields': []} def make_field(name='value', string='unknown', required=False, fields=[]): return [ @@ -71,3 +75,165 @@ class test_o2m(TransactionCase): ]}, {'id': 'value', 'name': 'value', 'string': 'unknown', 'required': False, 'fields': []}, ])) + +class test_match_headers_single(TransactionCase): + def test_match_by_name(self): + match = self.registry('base_import.import')._match_header( + 'f0', [{'name': 'f0'}], {}) + + self.assertEqual(match, [{'name': 'f0'}]) + + def test_match_by_string(self): + match = self.registry('base_import.import')._match_header( + 'some field', [{'name': 'bob', 'string': "Some Field"}], {}) + + self.assertEqual(match, [{'name': 'bob', 'string': "Some Field"}]) + + def test_nomatch(self): + match = self.registry('base_import.import')._match_header( + 'should not be', [{'name': 'bob', 'string': "wheee"}], {}) + + self.assertEqual(match, []) + + def test_recursive_match(self): + f = { + 'name': 'f0', + 'string': "My Field", + 'fields': [ + {'name': 'f0', 'string': "Sub field 0", 'fields': []}, + {'name': 'f1', 'string': "Sub field 2", 'fields': []}, + ] + } + match = self.registry('base_import.import')._match_header( + 'f0/f1', [f], {}) + + self.assertEqual(match, [f, f['fields'][1]]) + + def test_recursive_nomatch(self): + """ Match first level, fail to match second level + """ + f = { + 'name': 'f0', + 'string': "My Field", + 'fields': [ + {'name': 'f0', 'string': "Sub field 0", 'fields': []}, + {'name': 'f1', 'string': "Sub field 2", 'fields': []}, + ] + } + match = self.registry('base_import.import')._match_header( + 'f0/f2', [f], {}) + + self.assertEqual(match, []) + +class test_match_headers_multiple(TransactionCase): + def test_noheaders(self): + self.assertIsNone( + self.registry('base_import.import')._match_headers( + [], [], {})) + def test_nomatch(self): + self.assertEqual( + self.registry('base_import.import')._match_headers( + iter([ + ['foo', 'bar', 'baz', 'qux'], + ['v1', 'v2', 'v3', 'v4'], + ]), + [], + {'headers': True}), + dict((index, None) for index in range(4))) + + def test_mixed(self): + self.assertEqual( + self.registry('base_import.import')._match_headers( + iter(['foo bar baz qux/corge'.split()]), + [ + {'name': 'bar', 'string': 'Bar'}, + {'name': 'bob', 'string': 'Baz'}, + {'name': 'qux', 'string': 'Qux', 'fields': [ + {'name': 'corge', 'fields': []}, + ]} + ], + {'headers': True}), + { + 0: None, + 1: ['bar'], + 2: ['bob'], + 3: ['qux', 'corge'], + }) + +import base64, csv +class test_preview(TransactionCase): + def make_import(self): + Import = self.registry('base_import.import') + id = Import.create(self.cr, self.uid, { + 'res_model': 'res.users', + 'file': base64.b64encode( + u"로그인,언어\n".encode('euc_kr'), + "bob,1\n"), + }) + return Import, id + + def test_encoding(self): + Import, id = self.make_import() + result = Import.parse_preview(self.cr, self.uid, id, { + 'quote': '"', + 'separator': ',', + }) + self.assertTrue('error' in result) + + def test_csv_errors(self): + Import, id = self.make_import() + + result = Import.parse_preview(self.cr, self.uid, id, { + 'quote': 'foo', + 'separator': ',', + 'encoding': 'euc_kr', + }) + self.assertTrue('error' in result) + + def test_csv_errors(self): + Import, id = self.make_import() + + result = Import.parse_preview(self.cr, self.uid, id, { + 'quote': '"', + 'separator': 'bob', + 'encoding': 'euc_kr', + }) + self.assertTrue('error' in result) + + def test_success(self): + Import = self.registry('base_import.import') + id = Import.create(self.cr, self.uid, { + 'res_model': 'base_import.tests.models.preview', + 'file': base64.b64encode('name,Some Value,Counter\n' + 'foo,1,2\n' + 'bar,3,4\n' + 'qux,5,6\n') + }) + + result = Import.parse_preview(self.cr, self.uid, id, { + 'quote': '"', + 'separator': ',', + 'headers': True, + }) + + p = { + 'matches': {0: ['name'], 1: ['somevalue'], 2: None}, + 'fields': [ + {'id': 'id', 'name': 'id', 'string': 'External ID', 'required':False, 'fields': []}, + {'id': 'name', 'name': 'name', 'string': 'Name', 'required':False, 'fields': []}, + {'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required':True, 'fields': []}, + {'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required':False, 'fields': []}, + ], + 'preview': [ + ['foo', '1', '2'], + ['bar', '3', '4'], + ['qux', '5', '6'], + ] + } + + self.assertEqual(result['matches'], p['matches']) + # Order depends on iteration order of fields_get + self.assertItemsEqual(result['fields'], p['fields']) + self.assertEqual(result['preview'], p['preview']) + # Ensure we only have the response fields we expect + self.assertItemsEqual(result.keys(), ['matches', 'fields', 'preview']) From de09433413d60fb8e40529a57dad1abac05c9011 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 14 Aug 2012 11:38:13 +0200 Subject: [PATCH 03/25] [ADD] feeding of import data into import_data bzr revid: xmo@openerp.com-20120814093813-ql1uq1kb2766db0e --- addons/base_import/models.py | 88 +++++++++++++++++++++++++- addons/base_import/tests/test_cases.py | 72 +++++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/addons/base_import/models.py b/addons/base_import/models.py index db1050636ff..754467ed1c2 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -3,6 +3,8 @@ import codecs import csv import itertools import logging +import operator +import random try: from cStringIO import StringIO @@ -202,7 +204,7 @@ class ir_import(orm.TransientModel): :returns: {fields, matches, preview} | {error, preview} :rtype: {dict(str: dict(...)), dict(int, list(str)), list(list(str))} | {str, str} """ - record = self.browse(cr, uid, id, context=context) + (record,) = self.browse(cr, uid, [id], context=context) fields = self.get_fields(cr, uid, record.res_model, context=context) try: @@ -231,3 +233,87 @@ class ir_import(orm.TransientModel): 'preview': base64.b64decode(record.file)[:ERROR_PREVIEW_BYTES]\ .decode('iso-8859-1'), } + + def _convert_import_data(self, record, fields, options, context=None): + """ Extracts the input browse_record and fields list (with + ``False``-y placeholders for fields to *not* import) into a + format Model.import_data can use: a fields list without holes + and the precisely matching data matrix + + :param browse_record record: + :param list(str|bool): fields + :returns: (data, fields) + :rtype: (list(list(str)), list(str)) + """ + # Get indices for non-empty fields + indices = [index for index, field in enumerate(fields) if field] + # If only one index, itemgetter will return an atom rather + # than a 1-tuple + if len(indices) == 1: mapper = lambda row: [row[indices[0]]] + else: mapper = operator.itemgetter(*indices) + # Get only list of actually imported fields + import_fields = filter(None, fields) + + rows_to_import = self._read_csv(record, options) + if options.get('headers'): + rows_to_import = itertools.islice( + rows_to_import, 1, None) + data = [ + row for row in itertools.imap(mapper, rows_to_import) + # don't try inserting completely empty rows (e.g. from + # filtering out o2m fields) + if any(row) + ] + + return data, import_fields + + def do(self, cr, uid, id, fields, options, dryrun=False, context=None): + """ Actual execution of the import + + :param fields: import mapping: maps each column to a field, + ``False`` for the columns to ignore + :type fields: list(str|bool) + :param dict options: + :param bool dryrun: performs all import operations (and + validations) but rollbacks writes, allows + getting as much errors as possible without + the risk of clobbering the database. + :returns: A list of errors. If the list is empty the import + executed fully and correctly. If the list is + non-empty it contains dicts with 3 keys ``type`` the + type of error (``error|warning``); ``message`` the + error message associated with the error (a string) + and ``record`` the data which failed to import (or + ``false`` if that data isn't available or provided) + :rtype: list({type, message, record}) + """ + cr.execute('SAVEPOINT import') + + (record,) = self.browse(cr, uid, [id], context=context) + data, import_fields = self._convert_import_data( + record, fields, options, context=context) + + try: + (code, record, message, _wat) = self.pool[record.res_model].import_data( + cr, uid, import_fields, data, context=context) + except Exception, e: + # TODO: remove when exceptions stop being an "expected" + # behavior of import_data on some invalid input. + code, record, message = -1, None, str(e) + + if dryrun: + cr.execute('ROLLBACK TO SAVEPOINT import') + else: + cr.execute('RELEASE SAVEPOINT import') + + if code != -1: + return [] + + # TODO: add key for error location? + # TODO: error not within normal preview, how to display? Re-preview + # with higher count? + return [{ + 'type': 'error', + 'message': message, + 'record': record or False + }] diff --git a/addons/base_import/tests/test_cases.py b/addons/base_import/tests/test_cases.py index b10749bb585..7cd7cbc5d00 100644 --- a/addons/base_import/tests/test_cases.py +++ b/addons/base_import/tests/test_cases.py @@ -237,3 +237,75 @@ class test_preview(TransactionCase): self.assertEqual(result['preview'], p['preview']) # Ensure we only have the response fields we expect self.assertItemsEqual(result.keys(), ['matches', 'fields', 'preview']) + +class test_convert_import_data(TransactionCase): + """ Tests conversion of base_import.import input into data which + can be fed to Model.import_data + """ + def test_all(self): + Import = self.registry('base_import.import') + id = Import.create(self.cr, self.uid, { + 'res_model': 'base_import.tests.models.preview', + 'file': base64.b64encode('name,Some Value,Counter\n' + 'foo,1,2\n' + 'bar,3,4\n' + 'qux,5,6\n') + }) + record = Import.browse(self.cr, self.uid, id) + data, fields = Import._convert_import_data( + record, ['name', 'somevalue', 'othervalue'], + {'quote': '"', 'separator': ',', 'headers': True,}) + + self.assertItemsEqual(fields, ['name', 'somevalue', 'othervalue']) + self.assertItemsEqual(data, [ + ('foo', '1', '2'), + ('bar', '3', '4'), + ('qux', '5', '6'), + ]) + + def test_filtered(self): + """ If ``False`` is provided as field mapping for a column, + that column should be removed from importable data + """ + Import = self.registry('base_import.import') + id = Import.create(self.cr, self.uid, { + 'res_model': 'base_import.tests.models.preview', + 'file': base64.b64encode('name,Some Value,Counter\n' + 'foo,1,2\n' + 'bar,3,4\n' + 'qux,5,6\n') + }) + record = Import.browse(self.cr, self.uid, id) + data, fields = Import._convert_import_data( + record, ['name', False, 'othervalue'], + {'quote': '"', 'separator': ',', 'headers': True,}) + + self.assertItemsEqual(fields, ['name', 'othervalue']) + self.assertItemsEqual(data, [ + ('foo', '2'), + ('bar', '4'), + ('qux', '6'), + ]) + + def test_norow(self): + """ If a row is composed only of empty values (due to having + filtered out non-empty values from it), it should be removed + """ + Import = self.registry('base_import.import') + id = Import.create(self.cr, self.uid, { + 'res_model': 'base_import.tests.models.preview', + 'file': base64.b64encode('name,Some Value,Counter\n' + 'foo,1,2\n' + ',3,\n' + ',5,6\n') + }) + record = Import.browse(self.cr, self.uid, id) + data, fields = Import._convert_import_data( + record, ['name', False, 'othervalue'], + {'quote': '"', 'separator': ',', 'headers': True,}) + + self.assertItemsEqual(fields, ['name', 'othervalue']) + self.assertItemsEqual(data, [ + ('foo', '2'), + ('', '6'), + ]) From 84ea769476468a46ee3ef19f388dff080b93b4ce Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 14 Aug 2012 14:07:37 +0200 Subject: [PATCH 04/25] [IMP] add information to _match_headers bzr revid: xmo@openerp.com-20120814120737-0zuxqyjhnnx1aye3 --- addons/base_import/models.py | 22 +++++++----- addons/base_import/tests/test_cases.py | 48 ++++++++++++++------------ 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/addons/base_import/models.py b/addons/base_import/models.py index 754467ed1c2..07a3439419d 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -24,6 +24,7 @@ class ir_import(orm.TransientModel): 'res_model': fields.char('Model', size=64), 'file': fields.binary('File'), 'file_name': fields.char('File Name', size=None), + 'file_mime': fields.char('File Type', size=None), } def get_fields(self, cr, uid, model, context=None, @@ -174,19 +175,20 @@ class ir_import(orm.TransientModel): Will consume the first line of the ``rows`` iterator. - Returns either None (no title) or a dict mapping cell indices + Returns a pair of (None, None) if headers were not requested + or the list of headers and a dict mapping cell indices to key paths in the ``fields`` tree :param Iterator rows: :param dict fields: :param dict options: - :rtype: None | dict(int: list(str)) + :rtype: (None, None) | (list(str), dict(int: list(str))) """ if not options.get('headers'): - return None + return None, None headers = next(rows) - return dict( + return headers, dict( (index, [field['name'] for field in self._match_header(header, fields, options)] or None) for index, header in enumerate(headers) ) @@ -196,13 +198,16 @@ class ir_import(orm.TransientModel): fields-matching between the import's file data and the model's columns. + If the headers are not requested (not options.headers), + ``matches`` and ``headers`` are both ``False``. + :param id: identifier of the import :param int count: number of preview lines to generate :param options: format-specific options. CSV: {encoding, quote, separator, headers} :type options: {str, str, str, bool} - :returns: {fields, matches, preview} | {error, preview} - :rtype: {dict(str: dict(...)), dict(int, list(str)), list(list(str))} | {str, str} + :returns: {fields, matches, headers, preview} | {error, preview} + :rtype: {dict(str: dict(...)), dict(int, list(str)), list(str), list(list(str))} | {str, str} """ (record,) = self.browse(cr, uid, [id], context=context) fields = self.get_fields(cr, uid, record.res_model, context=context) @@ -210,13 +215,14 @@ class ir_import(orm.TransientModel): try: rows = self._read_csv(record, options) - match = self._match_headers(rows, fields, options) + headers, matches = self._match_headers(rows, fields, options) # Match should have consumed the first row (iif headers), get # the ``count`` next rows for preview preview = itertools.islice(rows, count) return { 'fields': fields, - 'matches': match, + 'matches': matches or False, + 'headers': headers or False, 'preview': list(preview), } except (TypeError, UnicodeDecodeError), e: diff --git a/addons/base_import/tests/test_cases.py b/addons/base_import/tests/test_cases.py index 7cd7cbc5d00..00230f9a3c0 100644 --- a/addons/base_import/tests/test_cases.py +++ b/addons/base_import/tests/test_cases.py @@ -127,9 +127,11 @@ class test_match_headers_single(TransactionCase): class test_match_headers_multiple(TransactionCase): def test_noheaders(self): - self.assertIsNone( + self.assertEqual( self.registry('base_import.import')._match_headers( - [], [], {})) + [], [], {}), + (None, None) + ) def test_nomatch(self): self.assertEqual( self.registry('base_import.import')._match_headers( @@ -139,7 +141,11 @@ class test_match_headers_multiple(TransactionCase): ]), [], {'headers': True}), - dict((index, None) for index in range(4))) + ( + ['foo', 'bar', 'baz', 'qux'], + dict.fromkeys(range(4)) + ) + ) def test_mixed(self): self.assertEqual( @@ -153,12 +159,13 @@ class test_match_headers_multiple(TransactionCase): ]} ], {'headers': True}), - { + (['foo', 'bar', 'baz', 'qux/corge'], { 0: None, 1: ['bar'], 2: ['bob'], 3: ['qux', 'corge'], }) + ) import base64, csv class test_preview(TransactionCase): @@ -216,27 +223,22 @@ class test_preview(TransactionCase): 'headers': True, }) - p = { - 'matches': {0: ['name'], 1: ['somevalue'], 2: None}, - 'fields': [ - {'id': 'id', 'name': 'id', 'string': 'External ID', 'required':False, 'fields': []}, - {'id': 'name', 'name': 'name', 'string': 'Name', 'required':False, 'fields': []}, - {'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required':True, 'fields': []}, - {'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required':False, 'fields': []}, - ], - 'preview': [ - ['foo', '1', '2'], - ['bar', '3', '4'], - ['qux', '5', '6'], - ] - } - - self.assertEqual(result['matches'], p['matches']) + self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None}) + self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter']) # Order depends on iteration order of fields_get - self.assertItemsEqual(result['fields'], p['fields']) - self.assertEqual(result['preview'], p['preview']) + self.assertItemsEqual(result['fields'], [ + {'id': 'id', 'name': 'id', 'string': 'External ID', 'required':False, 'fields': []}, + {'id': 'name', 'name': 'name', 'string': 'Name', 'required':False, 'fields': []}, + {'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required':True, 'fields': []}, + {'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required':False, 'fields': []}, + ]) + self.assertEqual(result['preview'], [ + ['foo', '1', '2'], + ['bar', '3', '4'], + ['qux', '5', '6'], + ]) # Ensure we only have the response fields we expect - self.assertItemsEqual(result.keys(), ['matches', 'fields', 'preview']) + self.assertItemsEqual(result.keys(), ['matches', 'headers', 'fields', 'preview']) class test_convert_import_data(TransactionCase): """ Tests conversion of base_import.import input into data which From b8f93a7bc653d41bd519e7769573fe2ccab8ccbe Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 14 Aug 2012 14:16:10 +0200 Subject: [PATCH 05/25] [ADD] base import logic: implement creating an import, uploading a file and displaying the preview bzr revid: xmo@openerp.com-20120814121610-j2rpg7u5zh4h93j1 --- addons/base_import/__init__.py | 1 + addons/base_import/__openerp__.py | 2 + addons/base_import/controllers.py | 24 ++++++ addons/base_import/static/src/js/import.js | 82 ++++++++++++++++++++ addons/base_import/static/src/xml/import.xml | 25 ++++++ 5 files changed, 134 insertions(+) create mode 100644 addons/base_import/controllers.py create mode 100644 addons/base_import/static/src/js/import.js create mode 100644 addons/base_import/static/src/xml/import.xml diff --git a/addons/base_import/__init__.py b/addons/base_import/__init__.py index f0736de3bd1..ab0a528f1f1 100644 --- a/addons/base_import/__init__.py +++ b/addons/base_import/__init__.py @@ -1,2 +1,3 @@ +import controllers import models import tests.models diff --git a/addons/base_import/__openerp__.py b/addons/base_import/__openerp__.py index 7f117349ba9..230c514efc8 100644 --- a/addons/base_import/__openerp__.py +++ b/addons/base_import/__openerp__.py @@ -27,4 +27,6 @@ Re-implement openerp's file import system: 'depends': ['base'], 'installable': True, 'auto_install': False, # set to true and allow uninstall? + 'js': ['static/src/js/import.js'], + 'qweb': ['static/src/xml/import.xml'], } diff --git a/addons/base_import/controllers.py b/addons/base_import/controllers.py new file mode 100644 index 00000000000..bc014cd8265 --- /dev/null +++ b/addons/base_import/controllers.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +import base64 +import simplejson + +try: + import openerp.addons.web.common.http as openerpweb +except ImportError: + import web.common.http as openerpweb + +class ImportController(openerpweb.Controller): + _cp_path = '/base_import' + + @openerpweb.httprequest + def set_file(self, req, file, import_id, jsonp='callback'): + import_id = int(import_id) + + written = req.session.model('base_import.import').write(import_id, { + 'file': base64.b64encode(file.read()), + 'file_name': file.filename, + 'file_type': file.content_type, + }, req.session.eval_context(req.context)) + + return 'window.top.%s(%s)' % ( + jsonp, simplejson.dumps({'result': written})) diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js new file mode 100644 index 00000000000..5bb81d23cbb --- /dev/null +++ b/addons/base_import/static/src/js/import.js @@ -0,0 +1,82 @@ +openerp.base_import = function (instance) { + var QWeb = instance.web.qweb; + var _lt = instance.web._lt; + + /** + * Safari does not deal well at all with raw JSON data being + * returned. As a result, we're going to cheat by using a + * pseudo-jsonp: instead of getting JSON data in the iframe, we're + * getting a ``script`` tag which consists of a function call and + * the returned data (the json dump). + * + * The function is an auto-generated name bound to ``window``, + * which calls back into the callback provided here. + * + * @param {Object} form the form element (DOM or jQuery) to use in the call + * @param {Object} attributes jquery.form attributes object + * @param {Function} callback function to call with the returned data + */ + function jsonp(form, attributes, callback) { + attributes = attributes || {}; + var options = {jsonp: _.uniqueId('import_callback_')}; + window[options.jsonp] = function () { + delete window[options.jsonp]; + callback.apply(null, arguments); + }; + if ('data' in attributes) { + _.extend(attributes.data, options); + } else { + _.extend(attributes, {data: options}); + } + _.extend(attributes, { + dataType: 'script', + }); + $(form).ajaxSubmit(attributes); + } + + instance.web.DataImport = instance.web.Dialog.extend({ + template: 'ImportView', + dialog_title: _lt("Import Data"), + events: { + 'change input.oe_import_file': 'file_update' + }, + init: function (parent, dataset) { + this._super(parent, {}); + this.res_model = parent.model; + // import object id + this.id = null; + this.Import = new instance.web.Model('base_import.import'); + }, + start: function () { + var self = this; + return this.Import.call('create', [{ + 'res_model': this.res_model + }]).then(function (id) { + self.id = id; + self.$('input[name=import_id]').val(id); + }); + }, + + //- File change section + file_update: function (e) { + if (!this.$('input.oe_import_file').val()) { return; } + + // TODO: hide preview before calling set_file + jsonp(this.$element, { + url: '/base_import/set_file' + }, this.proxy('file_updated')); + }, + file_updated: function () { + // immediately trigger preview... + // TODO: test that write // succeeded? + this.Import.call('parse_preview', [this.id, { + quote: '"', + separator: ',', + headers: true, + }]).then(this.proxy('preview')); + }, + preview: function (result) { + this.$('table').html(QWeb.render('ImportView.preview', result)); + }, + }); +}; diff --git a/addons/base_import/static/src/xml/import.xml b/addons/base_import/static/src/xml/import.xml new file mode 100644 index 00000000000..d2a3ec4f66d --- /dev/null +++ b/addons/base_import/static/src/xml/import.xml @@ -0,0 +1,25 @@ + + +
+ + + + + + +
+
+
+ + + + + + + + + +
From aec461651e2671dd6bb1caf7ee21430c861c109f Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 14 Aug 2012 14:31:12 +0200 Subject: [PATCH 06/25] [FIX] NUL byte in a file leads csv.reader to raise csv.Error bzr revid: xmo@openerp.com-20120814123112-p4iio7fl561ronln --- addons/base_import/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/base_import/models.py b/addons/base_import/models.py index 07a3439419d..722a9bf848e 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -225,7 +225,7 @@ class ir_import(orm.TransientModel): 'headers': headers or False, 'preview': list(preview), } - except (TypeError, UnicodeDecodeError), e: + except (csv.Error, TypeError, UnicodeDecodeError), e: # Due to lazy generators, UnicodeDecodeError (for # instance) may only be raised when serializing the # preview to a list in the return. From 323b979146d6e0ba428a3e15e98f112cf9f80d43 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 14 Aug 2012 14:48:35 +0200 Subject: [PATCH 07/25] [ADD] error reporting to user during preview bzr revid: xmo@openerp.com-20120814124835-9nkqni98ie2eyt5r --- addons/base_import/__openerp__.py | 1 + addons/base_import/models.py | 2 +- addons/base_import/static/src/js/import.js | 12 ++++++++++-- addons/base_import/static/src/xml/import.xml | 8 +++++++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/addons/base_import/__openerp__.py b/addons/base_import/__openerp__.py index 230c514efc8..ab97e237909 100644 --- a/addons/base_import/__openerp__.py +++ b/addons/base_import/__openerp__.py @@ -27,6 +27,7 @@ Re-implement openerp's file import system: 'depends': ['base'], 'installable': True, 'auto_install': False, # set to true and allow uninstall? + 'css': ['static/src/css/import.css'], 'js': ['static/src/js/import.js'], 'qweb': ['static/src/xml/import.xml'], } diff --git a/addons/base_import/models.py b/addons/base_import/models.py index 722a9bf848e..560a28f9067 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -231,7 +231,7 @@ class ir_import(orm.TransientModel): # preview to a list in the return. _logger.debug("Error during CSV parsing preview", exc_info=True) return { - 'error': _("Failed to parse CSV file: %s") % e, + 'error': str(e), # iso-8859-1 ensures decoding will always succeed, # even if it yields non-printable characters. This is # in case of UnicodeDecodeError (or csv.Error diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index 5bb81d23cbb..559d74734e8 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -61,7 +61,7 @@ openerp.base_import = function (instance) { file_update: function (e) { if (!this.$('input.oe_import_file').val()) { return; } - // TODO: hide preview before calling set_file + this.$element.removeClass('oe_import_preview oe_import_error'); jsonp(this.$element, { url: '/base_import/set_file' }, this.proxy('file_updated')); @@ -76,7 +76,15 @@ openerp.base_import = function (instance) { }]).then(this.proxy('preview')); }, preview: function (result) { - this.$('table').html(QWeb.render('ImportView.preview', result)); + if (result.error) { + this.$element.addClass('oe_import_error'); + this.$('.oe_import_error_report').html( + QWeb.render('ImportView.error', result)); + } else { + this.$element.addClass('oe_import_preview'); + this.$('table').html( + QWeb.render('ImportView.preview', result)); + } }, }); }; diff --git a/addons/base_import/static/src/xml/import.xml b/addons/base_import/static/src/xml/import.xml index d2a3ec4f66d..7cf04c55292 100644 --- a/addons/base_import/static/src/xml/import.xml +++ b/addons/base_import/static/src/xml/import.xml @@ -1,6 +1,6 @@ -
+ @@ -9,6 +9,7 @@
+
@@ -22,4 +23,9 @@ > + +

Import preview failed due to:

+

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

+
+
From 8fa23a95186edd2f5bf6f223337d34f3a4e55e70 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 14 Aug 2012 16:14:56 +0200 Subject: [PATCH 08/25] [ADD] actual importing, no fields matching yet also no error pinpointing because API does not allow for it currently bzr revid: xmo@openerp.com-20120814141456-zh9kdbjy8wj1sxys --- addons/base_import/models.py | 29 ++++++++-- addons/base_import/static/src/js/import.js | 58 +++++++++++++++++--- addons/base_import/static/src/xml/import.xml | 17 +++++- 3 files changed, 89 insertions(+), 15 deletions(-) diff --git a/addons/base_import/models.py b/addons/base_import/models.py index 560a28f9067..a464649758d 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -11,6 +11,8 @@ try: except ImportError: from StringIO import StringIO +import psycopg2 + from openerp.osv import orm, fields from openerp.tools.translate import _ @@ -300,24 +302,39 @@ class ir_import(orm.TransientModel): record, fields, options, context=context) try: + _logger.info('importing %d rows...', len(data)) (code, record, message, _wat) = self.pool[record.res_model].import_data( cr, uid, import_fields, data, context=context) + _logger.info('done') + except Exception, e: + _logger.exception("Import failed") # TODO: remove when exceptions stop being an "expected" - # behavior of import_data on some invalid input. + # behavior of import_data on some (most) invalid + # input. code, record, message = -1, None, str(e) - if dryrun: - cr.execute('ROLLBACK TO SAVEPOINT import') - else: - cr.execute('RELEASE SAVEPOINT import') + # If transaction aborted, RELEASE SAVEPOINT is going to raise + # an InternalError (ROLLBACK should work, maybe). Ignore that. + # TODO: to handle multiple errors, create savepoint around + # write and release it in case of write error (after + # adding error to errors array) => can keep on trying to + # import stuff, and rollback at the end if there is any + # error in the results. + try: + if dryrun: + cr.execute('ROLLBACK TO SAVEPOINT import') + else: + cr.execute('RELEASE SAVEPOINT import') + except psycopg2.InternalError: + pass if code != -1: return [] # TODO: add key for error location? # TODO: error not within normal preview, how to display? Re-preview - # with higher count? + # with higher ``count``? return [{ 'type': 'error', 'message': message, diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index 559d74734e8..037a9662b02 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -1,5 +1,6 @@ openerp.base_import = function (instance) { var QWeb = instance.web.qweb; + var _t = instance.web._t; var _lt = instance.web._lt; /** @@ -41,7 +42,14 @@ openerp.base_import = function (instance) { 'change input.oe_import_file': 'file_update' }, init: function (parent, dataset) { - this._super(parent, {}); + var self = this; + this._super(parent, { + buttons: [ + {text: _t("Import File"), click: function () { + self.do_import(); + }, 'class': 'oe_import_dialog_button'} + ] + }); this.res_model = parent.model; // import object id this.id = null; @@ -57,6 +65,15 @@ openerp.base_import = function (instance) { }); }, + import_options: function () { + return { + // TODO: customizable gangnam style + quote: '"', + separator: ',', + headers: true, + }; + }, + //- File change section file_update: function (e) { if (!this.$('input.oe_import_file').val()) { return; } @@ -69,22 +86,49 @@ openerp.base_import = function (instance) { file_updated: function () { // immediately trigger preview... // TODO: test that write // succeeded? - this.Import.call('parse_preview', [this.id, { - quote: '"', - separator: ',', - headers: true, - }]).then(this.proxy('preview')); + this.Import.call( + 'parse_preview', [this.id, this.import_options()]) + .then(this.proxy('preview')); }, preview: function (result) { if (result.error) { this.$element.addClass('oe_import_error'); this.$('.oe_import_error_report').html( - QWeb.render('ImportView.error', result)); + QWeb.render('ImportView.preview.error', result)); } else { this.$element.addClass('oe_import_preview'); this.$('table').html( QWeb.render('ImportView.preview', result)); } }, + + //- import itself + do_import: function () { + var fields = this.$('.oe_import_fields input').map(function (index, el) { + return el.value || false; + }).get(); + this.Import.call( + 'do', [this.id, fields, this.import_options()], { + // maybe could do a dryrun after successful + // preview or something (note: don't go to + // this.result if dryrun=true) + dryrun: false + }) + .then(this.proxy('result')); + }, + result: function (errors) { + if (!errors.length) { + if (this.getParent().reload_content) { + this.getParent().reload_content(); + } + this.close(); + return; + } + // import failed (or maybe just warnings, if we ever get + // warnings?) + this.$element.addClass('oe_import_error'); + this.$('.oe_import_error_report').html( + QWeb.render('ImportView.error', {errors: errors})); + }, }); }; diff --git a/addons/base_import/static/src/xml/import.xml b/addons/base_import/static/src/xml/import.xml index 7cf04c55292..483c9a4a3cb 100644 --- a/addons/base_import/static/src/xml/import.xml +++ b/addons/base_import/static/src/xml/import.xml @@ -7,9 +7,9 @@ +
-
@@ -18,14 +18,27 @@ + + + + + + - +

Import preview failed due to:

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

+
    +
  • + + +
  • +
From 34f9664377ae837a7080b88a24c87b6ed88f88d0 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 14 Aug 2012 16:29:33 +0200 Subject: [PATCH 09/25] [FIX] no field selected for import by the user bzr revid: xmo@openerp.com-20120814142933-myng67f2cily6vfg --- addons/base_import/models.py | 14 +++++++++-- addons/base_import/tests/test_cases.py | 32 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/addons/base_import/models.py b/addons/base_import/models.py index a464649758d..2e44c686fd7 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -252,9 +252,12 @@ class ir_import(orm.TransientModel): :param list(str|bool): fields :returns: (data, fields) :rtype: (list(list(str)), list(str)) + :raises ValueError: in case the import data could not be converted """ # Get indices for non-empty fields indices = [index for index, field in enumerate(fields) if field] + if not indices: + raise ValueError(_("You must configure at least one field to import")) # If only one index, itemgetter will return an atom rather # than a 1-tuple if len(indices) == 1: mapper = lambda row: [row[indices[0]]] @@ -298,8 +301,15 @@ class ir_import(orm.TransientModel): cr.execute('SAVEPOINT import') (record,) = self.browse(cr, uid, [id], context=context) - data, import_fields = self._convert_import_data( - record, fields, options, context=context) + try: + data, import_fields = self._convert_import_data( + record, fields, options, context=context) + except ValueError, e: + return [{ + 'type': 'error', + 'message': str(e), + 'record': False, + }] try: _logger.info('importing %d rows...', len(data)) diff --git a/addons/base_import/tests/test_cases.py b/addons/base_import/tests/test_cases.py index 00230f9a3c0..e2b61c1928b 100644 --- a/addons/base_import/tests/test_cases.py +++ b/addons/base_import/tests/test_cases.py @@ -311,3 +311,35 @@ class test_convert_import_data(TransactionCase): ('foo', '2'), ('', '6'), ]) + + def test_nofield(self): + Import = self.registry('base_import.import') + + id = Import.create(self.cr, self.uid, { + 'res_model': 'base_import.tests.models.preview', + 'file': base64.b64encode('name,Some Value,Counter\n' + 'foo,1,2\n') + }) + + record = Import.browse(self.cr, self.uid, id) + self.assertRaises( + ValueError, + Import._convert_import_data, + record, [], + {'quote': '"', 'separator': ',', 'headers': True,}) + + def test_falsefields(self): + Import = self.registry('base_import.import') + + id = Import.create(self.cr, self.uid, { + 'res_model': 'base_import.tests.models.preview', + 'file': base64.b64encode('name,Some Value,Counter\n' + 'foo,1,2\n') + }) + + record = Import.browse(self.cr, self.uid, id) + self.assertRaises( + ValueError, + Import._convert_import_data, + record, [False, False, False], + {'quote': '"', 'separator': ',', 'headers': True,}) From 7f319f5a374842a2731e7fbacb468452be784c69 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 14 Aug 2012 19:23:22 +0200 Subject: [PATCH 10/25] [ADD] blahblahblahblah also basic CSS effects bzr revid: xmo@openerp.com-20120814172322-1hx09qqh90umjdcq --- addons/base_import/static/src/css/import.css | 27 ++++++++++++ addons/base_import/static/src/js/import.js | 27 +++++++++--- addons/base_import/static/src/xml/import.xml | 46 +++++++++++++++++--- 3 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 addons/base_import/static/src/css/import.css diff --git a/addons/base_import/static/src/css/import.css b/addons/base_import/static/src/css/import.css new file mode 100644 index 00000000000..4bb1fdeaea0 --- /dev/null +++ b/addons/base_import/static/src/css/import.css @@ -0,0 +1,27 @@ +.oe_import dd, +.oe_import .oe_import_grid, +.oe_import .oe_import_error_report, +.oe_import .oe_import_with_file, +.oe_import .oe_import_noheaders { + display: none; +} + +.oe_import.oe_import_preview .oe_import_grid { + display: table; +} +.oe_import.oe_import_error .oe_import_error_report, +.oe_import.oe_import_with_file .oe_import_with_file, +.oe_import.oe_import_noheaders .oe_import_noheaders { + display: block; +} + +.oe_import .oe_import_error_report ul .oe_import_report_error { + background-color: #FFD9DB; +} +.oe_import .oe_import_error_report ul .oe_import_report_warning { + background-color: #FEFFD9; +} + +.oe_import .oe_import_noheaders { + color: #888; +} diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index 037a9662b02..3a52ad548ec 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -39,7 +39,22 @@ openerp.base_import = function (instance) { template: 'ImportView', dialog_title: _lt("Import Data"), events: { - 'change input.oe_import_file': 'file_update' + 'change input.oe_import_file': 'file_update', + 'click input.oe_import_has_header': function (e) { + this.$element.toggleClass( + 'oe_import_noheaders', !e.target.checked); + this.settings_updated(); + }, + 'click a.oe_import_csv': function (e) { + e.preventDefault(); + }, + 'click a.oe_import_export': function (e) { + e.preventDefault(); + }, + 'click dt a': function (e) { + e.preventDefault(); + $(e.target).parent().next().toggle(); + } }, init: function (parent, dataset) { var self = this; @@ -70,21 +85,21 @@ openerp.base_import = function (instance) { // TODO: customizable gangnam style quote: '"', separator: ',', - headers: true, + headers: this.$('input.oe_import_has_header').prop('checked'), }; }, - //- File change section + //- File & settings change section file_update: function (e) { if (!this.$('input.oe_import_file').val()) { return; } this.$element.removeClass('oe_import_preview oe_import_error'); jsonp(this.$element, { url: '/base_import/set_file' - }, this.proxy('file_updated')); + }, this.proxy('settings_updated')); }, - file_updated: function () { - // immediately trigger preview... + settings_updated: function () { + this.$element.addClass('oe_import_with_file'); // TODO: test that write // succeeded? this.Import.call( 'parse_preview', [this.id, this.import_options()]) diff --git a/addons/base_import/static/src/xml/import.xml b/addons/base_import/static/src/xml/import.xml index 483c9a4a3cb..bb4f1d742d5 100644 --- a/addons/base_import/static/src/xml/import.xml +++ b/addons/base_import/static/src/xml/import.xml @@ -4,15 +4,51 @@ - +

Upload your file

+

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

+ -
- -
+
+

Map your data to OpenERP

+ + +

If the file contains + the column names, OpenERP can try auto-detecting the + field corresponding to the column. This makes imports + simpler especially when the file has many columns.

+ +
+ + + +

Frequently Asked Questions

+
+
Need to import data from an other application?
+
+

In order to re-create relationships between + different records, you should use the unique + identifier from the original application and + map it to the ID + column in OpenERP. When you + import an other record that links to the first + one, use XXX/ID + to the original unique identifier.

+

The ID + will also be used to update the original + import if you need to re-import modified data + later, it's thus good practice to specify it + whenever possible

+
+
+ -
Date: Thu, 30 Aug 2012 12:32:15 +0200 Subject: [PATCH 11/25] [IMP] un-b64 the import file field, use raw binary bzr revid: xmo@openerp.com-20120830103215-trv5cjg9jvj8ano1 --- addons/base_import/controllers.py | 2 +- addons/base_import/models.py | 12 +++++------ addons/base_import/tests/test_cases.py | 29 ++++++++++++-------------- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/addons/base_import/controllers.py b/addons/base_import/controllers.py index bc014cd8265..d978730b482 100644 --- a/addons/base_import/controllers.py +++ b/addons/base_import/controllers.py @@ -15,7 +15,7 @@ class ImportController(openerpweb.Controller): import_id = int(import_id) written = req.session.model('base_import.import').write(import_id, { - 'file': base64.b64encode(file.read()), + 'file': file.read(), 'file_name': file.filename, 'file_type': file.content_type, }, req.session.eval_context(req.context)) diff --git a/addons/base_import/models.py b/addons/base_import/models.py index 2e44c686fd7..f340b582eaa 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -1,10 +1,7 @@ -import base64 -import codecs import csv import itertools import logging import operator -import random try: from cStringIO import StringIO @@ -24,7 +21,8 @@ class ir_import(orm.TransientModel): _columns = { 'res_model': fields.char('Model', size=64), - 'file': fields.binary('File'), + 'file': fields.binary( + 'File', help="File to check and/or import, raw binary (not base64)"), 'file_name': fields.char('File Name', size=None), 'file_mime': fields.char('File Type', size=None), } @@ -123,7 +121,7 @@ class ir_import(orm.TransientModel): :throws UnicodeDecodeError: if ``options.encoding`` is incorrect """ csv_iterator = csv.reader( - StringIO(base64.b64decode(record.file)), + StringIO(record.file), quotechar=options['quote'], delimiter=options['separator']) csv_nonempty = itertools.ifilter(None, csv_iterator) @@ -238,8 +236,8 @@ class ir_import(orm.TransientModel): # even if it yields non-printable characters. This is # in case of UnicodeDecodeError (or csv.Error # compounded with UnicodeDecodeError) - 'preview': base64.b64decode(record.file)[:ERROR_PREVIEW_BYTES]\ - .decode('iso-8859-1'), + 'preview': record.file[:ERROR_PREVIEW_BYTES] + .decode( 'iso-8859-1'), } def _convert_import_data(self, record, fields, options, context=None): diff --git a/addons/base_import/tests/test_cases.py b/addons/base_import/tests/test_cases.py index e2b61c1928b..420140cd2ac 100644 --- a/addons/base_import/tests/test_cases.py +++ b/addons/base_import/tests/test_cases.py @@ -167,15 +167,12 @@ class test_match_headers_multiple(TransactionCase): }) ) -import base64, csv class test_preview(TransactionCase): def make_import(self): Import = self.registry('base_import.import') id = Import.create(self.cr, self.uid, { 'res_model': 'res.users', - 'file': base64.b64encode( - u"로그인,언어\n".encode('euc_kr'), - "bob,1\n"), + 'file': u"로그인,언어\nbob,1\n".encode('euc_kr'), }) return Import, id @@ -211,10 +208,10 @@ class test_preview(TransactionCase): Import = self.registry('base_import.import') id = Import.create(self.cr, self.uid, { 'res_model': 'base_import.tests.models.preview', - 'file': base64.b64encode('name,Some Value,Counter\n' + 'file': 'name,Some Value,Counter\n' 'foo,1,2\n' 'bar,3,4\n' - 'qux,5,6\n') + 'qux,5,6\n' }) result = Import.parse_preview(self.cr, self.uid, id, { @@ -248,10 +245,10 @@ class test_convert_import_data(TransactionCase): Import = self.registry('base_import.import') id = Import.create(self.cr, self.uid, { 'res_model': 'base_import.tests.models.preview', - 'file': base64.b64encode('name,Some Value,Counter\n' + 'file': 'name,Some Value,Counter\n' 'foo,1,2\n' 'bar,3,4\n' - 'qux,5,6\n') + 'qux,5,6\n' }) record = Import.browse(self.cr, self.uid, id) data, fields = Import._convert_import_data( @@ -272,10 +269,10 @@ class test_convert_import_data(TransactionCase): Import = self.registry('base_import.import') id = Import.create(self.cr, self.uid, { 'res_model': 'base_import.tests.models.preview', - 'file': base64.b64encode('name,Some Value,Counter\n' + 'file': 'name,Some Value,Counter\n' 'foo,1,2\n' 'bar,3,4\n' - 'qux,5,6\n') + 'qux,5,6\n' }) record = Import.browse(self.cr, self.uid, id) data, fields = Import._convert_import_data( @@ -296,10 +293,10 @@ class test_convert_import_data(TransactionCase): Import = self.registry('base_import.import') id = Import.create(self.cr, self.uid, { 'res_model': 'base_import.tests.models.preview', - 'file': base64.b64encode('name,Some Value,Counter\n' + 'file': 'name,Some Value,Counter\n' 'foo,1,2\n' ',3,\n' - ',5,6\n') + ',5,6\n' }) record = Import.browse(self.cr, self.uid, id) data, fields = Import._convert_import_data( @@ -317,8 +314,8 @@ class test_convert_import_data(TransactionCase): id = Import.create(self.cr, self.uid, { 'res_model': 'base_import.tests.models.preview', - 'file': base64.b64encode('name,Some Value,Counter\n' - 'foo,1,2\n') + 'file': 'name,Some Value,Counter\n' + 'foo,1,2\n' }) record = Import.browse(self.cr, self.uid, id) @@ -333,8 +330,8 @@ class test_convert_import_data(TransactionCase): id = Import.create(self.cr, self.uid, { 'res_model': 'base_import.tests.models.preview', - 'file': base64.b64encode('name,Some Value,Counter\n' - 'foo,1,2\n') + 'file': 'name,Some Value,Counter\n' + 'foo,1,2\n' }) record = Import.browse(self.cr, self.uid, id) From 246d9bd37f8cfe36a17f4e4d1ce89c8f44ff39a3 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 30 Aug 2012 12:32:45 +0200 Subject: [PATCH 12/25] [IMP] rename $element to $el bzr revid: xmo@openerp.com-20120830103245-3x8hzqquwqg27sji --- addons/base_import/static/src/js/import.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index 3a52ad548ec..81b7397ba00 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -41,7 +41,7 @@ openerp.base_import = function (instance) { events: { 'change input.oe_import_file': 'file_update', 'click input.oe_import_has_header': function (e) { - this.$element.toggleClass( + this.$el.toggleClass( 'oe_import_noheaders', !e.target.checked); this.settings_updated(); }, @@ -93,13 +93,13 @@ openerp.base_import = function (instance) { file_update: function (e) { if (!this.$('input.oe_import_file').val()) { return; } - this.$element.removeClass('oe_import_preview oe_import_error'); - jsonp(this.$element, { + this.$el.removeClass('oe_import_preview oe_import_error'); + jsonp(this.$el, { url: '/base_import/set_file' }, this.proxy('settings_updated')); }, settings_updated: function () { - this.$element.addClass('oe_import_with_file'); + this.$el.addClass('oe_import_with_file'); // TODO: test that write // succeeded? this.Import.call( 'parse_preview', [this.id, this.import_options()]) @@ -107,11 +107,11 @@ openerp.base_import = function (instance) { }, preview: function (result) { if (result.error) { - this.$element.addClass('oe_import_error'); + this.$el.addClass('oe_import_error'); this.$('.oe_import_error_report').html( QWeb.render('ImportView.preview.error', result)); } else { - this.$element.addClass('oe_import_preview'); + this.$el.addClass('oe_import_preview'); this.$('table').html( QWeb.render('ImportView.preview', result)); } @@ -141,7 +141,7 @@ openerp.base_import = function (instance) { } // import failed (or maybe just warnings, if we ever get // warnings?) - this.$element.addClass('oe_import_error'); + this.$el.addClass('oe_import_error'); this.$('.oe_import_error_report').html( QWeb.render('ImportView.error', {errors: errors})); }, From b1407fc769bdde6a45c8214a7c74fbecfbae6234 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 30 Aug 2012 12:39:28 +0200 Subject: [PATCH 13/25] [FIX] field for file mime type is file_type, not file_mime bzr revid: xmo@openerp.com-20120830103928-rjtrdvkzrybfpi0c --- addons/base_import/controllers.py | 1 - addons/base_import/models.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/addons/base_import/controllers.py b/addons/base_import/controllers.py index d978730b482..85ff993e92e 100644 --- a/addons/base_import/controllers.py +++ b/addons/base_import/controllers.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -import base64 import simplejson try: diff --git a/addons/base_import/models.py b/addons/base_import/models.py index f340b582eaa..80c6df70a74 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -24,7 +24,7 @@ class ir_import(orm.TransientModel): 'file': fields.binary( 'File', help="File to check and/or import, raw binary (not base64)"), 'file_name': fields.char('File Name', size=None), - 'file_mime': fields.char('File Type', size=None), + 'file_type': fields.char('File Type', size=None), } def get_fields(self, cr, uid, model, context=None, From ca6323607e4b382a3fb9f54a841f35f529267622 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 30 Aug 2012 13:06:03 +0200 Subject: [PATCH 14/25] [IMP] make import objects live for 12 hours when transient cron is active bzr revid: xmo@openerp.com-20120830110603-8stvh3vpsn4vzr4f --- addons/base_import/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/addons/base_import/models.py b/addons/base_import/models.py index 80c6df70a74..0f1d8c5705e 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -18,6 +18,8 @@ ERROR_PREVIEW_BYTES = 200 _logger = logging.getLogger(__name__) class ir_import(orm.TransientModel): _name = 'base_import.import' + # allow imports to survive for 12h in case user is slow + _transient_max_hours = 12.0 _columns = { 'res_model': fields.char('Model', size=64), From 302cabfccb151480895cf9bd8759701667f3a999 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 30 Aug 2012 14:52:32 +0200 Subject: [PATCH 15/25] [IMP] Make CSV options (quoting and separator) customizable Also autogen input ids so labels work correctly without collision risks, and make toggling links more flexible bzr revid: xmo@openerp.com-20120830125232-d7g7s5oid4qivaq2 --- addons/base_import/static/src/css/import.css | 8 ++++++ addons/base_import/static/src/js/import.js | 26 ++++++++++++-------- addons/base_import/static/src/xml/import.xml | 22 ++++++++++++++--- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/addons/base_import/static/src/css/import.css b/addons/base_import/static/src/css/import.css index 4bb1fdeaea0..63b53a9e5c1 100644 --- a/addons/base_import/static/src/css/import.css +++ b/addons/base_import/static/src/css/import.css @@ -1,4 +1,5 @@ .oe_import dd, +.oe_import .oe_import_toggled, .oe_import .oe_import_grid, .oe_import .oe_import_error_report, .oe_import .oe_import_with_file, @@ -25,3 +26,10 @@ .oe_import .oe_import_noheaders { color: #888; } + +.oe_import a.oe_import_toggle { + display: block; +} +.oe_import a.oe_import_toggle:before { + content: '> ' +} diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index 81b7397ba00..99397304d16 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -38,22 +38,26 @@ openerp.base_import = function (instance) { instance.web.DataImport = instance.web.Dialog.extend({ template: 'ImportView', dialog_title: _lt("Import Data"), + defaults: { + quoting: '"', + separator: ',', + }, events: { 'change input.oe_import_file': 'file_update', - 'click input.oe_import_has_header': function (e) { - this.$el.toggleClass( - 'oe_import_noheaders', !e.target.checked); - this.settings_updated(); - }, + 'change input:not(.oe_import_file)': 'settings_updated', 'click a.oe_import_csv': function (e) { e.preventDefault(); }, 'click a.oe_import_export': function (e) { e.preventDefault(); }, - 'click dt a': function (e) { + 'click a.oe_import_toggle': function (e) { e.preventDefault(); - $(e.target).parent().next().toggle(); + var $el = $(e.target); + ($el.next().length + ? $el.next() + : $el.parent().next()) + .toggle(); } }, init: function (parent, dataset) { @@ -82,9 +86,8 @@ openerp.base_import = function (instance) { import_options: function () { return { - // TODO: customizable gangnam style - quote: '"', - separator: ',', + quote: this.$('input.oe_import_quoting').val(), + separator: this.$('input.oe_import_separator').val(), headers: this.$('input.oe_import_has_header').prop('checked'), }; }, @@ -106,6 +109,9 @@ openerp.base_import = function (instance) { .then(this.proxy('preview')); }, preview: function (result) { + this.$el.toggleClass( + 'oe_import_noheaders', + !this.$('input.oe_import_has_header').prop('checked')); if (result.error) { this.$el.addClass('oe_import_error'); this.$('.oe_import_error_report').html( diff --git a/addons/base_import/static/src/xml/import.xml b/addons/base_import/static/src/xml/import.xml index bb4f1d742d5..a85f8e960c1 100644 --- a/addons/base_import/static/src/xml/import.xml +++ b/addons/base_import/static/src/xml/import.xml @@ -1,5 +1,6 @@ +
@@ -9,8 +10,9 @@ file to import. If you need a sample importable file, you can use the export tool to generate one.

- - + +

Map your data to OpenERP

@@ -25,11 +27,23 @@
- + + File Format Options… +
+ + + +
+ + +

Frequently Asked Questions

-
Need to import data from an other application?
+
+ Need to import data from an other application?

In order to re-create relationships between different records, you should use the unique From cdb686980986b0fc200483b90dbb18fbdda6c585 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 30 Aug 2012 16:12:26 +0200 Subject: [PATCH 16/25] [IMP] add encoding csv option and generify handling of options a bit, imprive styling, and rename 'quote' to 'quoting' bzr revid: xmo@openerp.com-20120830141226-exli9s1hh4vij6sd --- addons/base_import/models.py | 6 +++--- addons/base_import/static/src/css/import.css | 10 ++++++++++ addons/base_import/static/src/js/import.js | 21 ++++++++++++-------- addons/base_import/static/src/xml/import.xml | 18 ++++++++--------- addons/base_import/tests/test_cases.py | 18 ++++++++--------- 5 files changed, 44 insertions(+), 29 deletions(-) diff --git a/addons/base_import/models.py b/addons/base_import/models.py index 0f1d8c5705e..51fac2af91c 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -124,10 +124,10 @@ class ir_import(orm.TransientModel): """ csv_iterator = csv.reader( StringIO(record.file), - quotechar=options['quote'], + quotechar=options['quoting'], delimiter=options['separator']) csv_nonempty = itertools.ifilter(None, csv_iterator) - # TODO: guess encoding? + # TODO: guess encoding with chardet? Or https://github.com/aadsm/jschardet encoding = options.get('encoding', 'utf-8') return itertools.imap( lambda row: [item.decode(encoding) for item in row], @@ -206,7 +206,7 @@ class ir_import(orm.TransientModel): :param id: identifier of the import :param int count: number of preview lines to generate :param options: format-specific options. - CSV: {encoding, quote, separator, headers} + CSV: {encoding, quoting, separator, headers} :type options: {str, str, str, bool} :returns: {fields, matches, headers, preview} | {error, preview} :rtype: {dict(str: dict(...)), dict(int, list(str)), list(str), list(list(str))} | {str, str} diff --git a/addons/base_import/static/src/css/import.css b/addons/base_import/static/src/css/import.css index 63b53a9e5c1..207f2aed9c6 100644 --- a/addons/base_import/static/src/css/import.css +++ b/addons/base_import/static/src/css/import.css @@ -33,3 +33,13 @@ .oe_import a.oe_import_toggle:before { content: '> ' } + +.oe_import .oe_import_options p { + margin: 0; + padding: 0; +} +.oe_import .oe_import_options label { + display: inline-block; + width: 10em; + text-align: right; +} diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index 99397304d16..2853f36019a 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -38,10 +38,11 @@ openerp.base_import = function (instance) { instance.web.DataImport = instance.web.Dialog.extend({ template: 'ImportView', dialog_title: _lt("Import Data"), - defaults: { - quoting: '"', - separator: ',', - }, + opts: [ + {name: 'encoding', label: _lt("Encoding:"), value: 'utf-8'}, + {name: 'separator', label: _lt("Separator:"), value: ','}, + {name: 'quoting', label: _lt("Quoting:"), value: '"'} + ], events: { 'change input.oe_import_file': 'file_update', 'change input:not(.oe_import_file)': 'settings_updated', @@ -85,11 +86,15 @@ openerp.base_import = function (instance) { }, import_options: function () { - return { - quote: this.$('input.oe_import_quoting').val(), - separator: this.$('input.oe_import_separator').val(), - headers: this.$('input.oe_import_has_header').prop('checked'), + var self = this; + var options = { + headers: this.$('input.oe_import_has_header').prop('checked') }; + _(this.opts).each(function (opt) { + options[opt.name] = + self.$('input.oe_import_' + opt.name).val(); + }); + return options; }, //- File & settings change section diff --git a/addons/base_import/static/src/xml/import.xml b/addons/base_import/static/src/xml/import.xml index a85f8e960c1..a2844f8d7b9 100644 --- a/addons/base_import/static/src/xml/import.xml +++ b/addons/base_import/static/src/xml/import.xml @@ -29,15 +29,15 @@

File Format Options… -
- - - -
- - +
+

+ + + +

Frequently Asked Questions

diff --git a/addons/base_import/tests/test_cases.py b/addons/base_import/tests/test_cases.py index 420140cd2ac..5479ae94428 100644 --- a/addons/base_import/tests/test_cases.py +++ b/addons/base_import/tests/test_cases.py @@ -179,7 +179,7 @@ class test_preview(TransactionCase): def test_encoding(self): Import, id = self.make_import() result = Import.parse_preview(self.cr, self.uid, id, { - 'quote': '"', + 'quoting': '"', 'separator': ',', }) self.assertTrue('error' in result) @@ -188,7 +188,7 @@ class test_preview(TransactionCase): Import, id = self.make_import() result = Import.parse_preview(self.cr, self.uid, id, { - 'quote': 'foo', + 'quoting': 'foo', 'separator': ',', 'encoding': 'euc_kr', }) @@ -198,7 +198,7 @@ class test_preview(TransactionCase): Import, id = self.make_import() result = Import.parse_preview(self.cr, self.uid, id, { - 'quote': '"', + 'quoting': '"', 'separator': 'bob', 'encoding': 'euc_kr', }) @@ -215,7 +215,7 @@ class test_preview(TransactionCase): }) result = Import.parse_preview(self.cr, self.uid, id, { - 'quote': '"', + 'quoting': '"', 'separator': ',', 'headers': True, }) @@ -253,7 +253,7 @@ class test_convert_import_data(TransactionCase): record = Import.browse(self.cr, self.uid, id) data, fields = Import._convert_import_data( record, ['name', 'somevalue', 'othervalue'], - {'quote': '"', 'separator': ',', 'headers': True,}) + {'quoting': '"', 'separator': ',', 'headers': True,}) self.assertItemsEqual(fields, ['name', 'somevalue', 'othervalue']) self.assertItemsEqual(data, [ @@ -277,7 +277,7 @@ class test_convert_import_data(TransactionCase): record = Import.browse(self.cr, self.uid, id) data, fields = Import._convert_import_data( record, ['name', False, 'othervalue'], - {'quote': '"', 'separator': ',', 'headers': True,}) + {'quoting': '"', 'separator': ',', 'headers': True,}) self.assertItemsEqual(fields, ['name', 'othervalue']) self.assertItemsEqual(data, [ @@ -301,7 +301,7 @@ class test_convert_import_data(TransactionCase): record = Import.browse(self.cr, self.uid, id) data, fields = Import._convert_import_data( record, ['name', False, 'othervalue'], - {'quote': '"', 'separator': ',', 'headers': True,}) + {'quoting': '"', 'separator': ',', 'headers': True,}) self.assertItemsEqual(fields, ['name', 'othervalue']) self.assertItemsEqual(data, [ @@ -323,7 +323,7 @@ class test_convert_import_data(TransactionCase): ValueError, Import._convert_import_data, record, [], - {'quote': '"', 'separator': ',', 'headers': True,}) + {'quoting': '"', 'separator': ',', 'headers': True,}) def test_falsefields(self): Import = self.registry('base_import.import') @@ -339,4 +339,4 @@ class test_convert_import_data(TransactionCase): ValueError, Import._convert_import_data, record, [False, False, False], - {'quote': '"', 'separator': ',', 'headers': True,}) + {'quoting': '"', 'separator': ',', 'headers': True,}) From 7c599159a584c74115a65aa632c2078c17f1b3c0 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 3 Sep 2012 11:12:11 +0200 Subject: [PATCH 17/25] [ADD] match display and field autocompletion UI bzr revid: xmo@openerp.com-20120903091211-0r0jwe786olxg8ll --- addons/base_import/static/src/js/import.js | 63 +++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index 2853f36019a..efcb756feb5 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -45,7 +45,7 @@ openerp.base_import = function (instance) { ], events: { 'change input.oe_import_file': 'file_update', - 'change input:not(.oe_import_file)': 'settings_updated', + 'change input.oe_import_has_header, .oe_import_options input': 'settings_updated', 'click a.oe_import_csv': function (e) { e.preventDefault(); }, @@ -74,6 +74,7 @@ openerp.base_import = function (instance) { // import object id this.id = null; this.Import = new instance.web.Model('base_import.import'); + this.field_label_to_name = {}; }, start: function () { var self = this; @@ -121,17 +122,67 @@ openerp.base_import = function (instance) { this.$el.addClass('oe_import_error'); this.$('.oe_import_error_report').html( QWeb.render('ImportView.preview.error', result)); - } else { - this.$el.addClass('oe_import_preview'); - this.$('table').html( - QWeb.render('ImportView.preview', result)); + return; } + this.$el.addClass('oe_import_preview'); + this.$('table').html(QWeb.render('ImportView.preview', result)); + + var $fields = this.$('.oe_import_fields input'); + this.render_fields_matches(result, $fields); + $fields.autocomplete({ + minLength: 0, + source: this.generate_fields_completion(result) + }); + }, + generate_fields_completion: function (root) { + // Display nice names to user, but send logical names to server + this.field_label_to_name = {}; + var completions = []; + var self = this; + function traverse(field, ancestors) { + var field_path = ancestors.concat(field); + var label = _(field_path).pluck('string').join(' / '); + self.field_label_to_name[label] = _(field_path).pluck('name').join('/'); + completions.push(label); + + var subfields = field.fields; + for(var i=0, end=subfields.length; i Date: Mon, 3 Sep 2012 13:24:18 +0200 Subject: [PATCH 18/25] [ADD] autocompletion sections, partitioning bzr revid: xmo@openerp.com-20120903112418-nhgmb6fs50ju0cfz --- addons/base_import/static/src/css/import.css | 13 ++++++ addons/base_import/static/src/js/import.js | 46 +++++++++++++++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/addons/base_import/static/src/css/import.css b/addons/base_import/static/src/css/import.css index 207f2aed9c6..c613357537b 100644 --- a/addons/base_import/static/src/css/import.css +++ b/addons/base_import/static/src/css/import.css @@ -43,3 +43,16 @@ width: 10em; text-align: right; } + +.ui-menu .oe_import_completion_category { + background-color: #f0f0f0; + /* jquery-ui requirements otherwise background color is buggy */ + float: left; + zoom: 1; + width: 100%; + padding: 0; + margin: 0; +} +.ui-menu .ui-menu-item .oe_import_completion_required { + color: #2962ff; +} diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index efcb756feb5..797a094b390 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -131,30 +131,62 @@ openerp.base_import = function (instance) { this.render_fields_matches(result, $fields); $fields.autocomplete({ minLength: 0, + html: true, source: this.generate_fields_completion(result) - }); + }).data('autocomplete')._renderItem = function (ul, item) { + var $item = $('
  • ').data('item.autocomplete', item).appendTo(ul); + + if (item.category) { + return $item + .addClass('oe_import_completion_category') + .text(item.label); + } else { + return $item.append( + $('') + .toggleClass('oe_import_completion_required', !!item.required) + .text(item.label)); + } + }; }, generate_fields_completion: function (root) { // Display nice names to user, but send logical names to server this.field_label_to_name = {}; - var completions = []; + var basic = []; + var regulars = [{label: _t("Normal Fields"), category: true}]; + var o2m = [{label: _t("Relation Fields (o2m)"), category: true}]; var self = this; - function traverse(field, ancestors) { + function traverse(field, ancestors, collection) { + var subfields = field.fields; var field_path = ancestors.concat(field); var label = _(field_path).pluck('string').join(' / '); self.field_label_to_name[label] = _(field_path).pluck('name').join('/'); - completions.push(label); - var subfields = field.fields; + // If non-relational, m2o or m2m, collection is regulars + if (!collection) { + if (field.name === 'id') { + collection = basic + } else if (_.isEmpty(subfields) + || _.isEqual(_.pluck(subfields, 'name'), ['id', '.id'])) { + collection = regulars; + } else { + collection = o2m; + } + } + + collection.push({ + label: label, + required: field.required + }); + for(var i=0, end=subfields.length; i Date: Mon, 3 Sep 2012 16:00:22 +0200 Subject: [PATCH 19/25] [IMP] catch-and-return all errors in parse_preview bzr revid: xmo@openerp.com-20120903140022-osfkgk4l6r2hj42j --- addons/base_import/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/base_import/models.py b/addons/base_import/models.py index 51fac2af91c..1e5ad002b84 100644 --- a/addons/base_import/models.py +++ b/addons/base_import/models.py @@ -227,7 +227,7 @@ class ir_import(orm.TransientModel): 'headers': headers or False, 'preview': list(preview), } - except (csv.Error, TypeError, UnicodeDecodeError), e: + except Exception, e: # Due to lazy generators, UnicodeDecodeError (for # instance) may only be raised when serializing the # preview to a list in the return. From 5f89461c60c3820c116d76ea7a7a8ea3f83c074f Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 3 Sep 2012 16:21:10 +0200 Subject: [PATCH 20/25] [IMP] add validation call after each field matching (slooooooowwwwww) bzr revid: xmo@openerp.com-20120903142110-0mziyxhcku22gbm8 --- addons/base_import/static/src/js/import.js | 38 ++++++++++++++-------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index 797a094b390..ca0f7e04ea7 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -44,6 +44,7 @@ openerp.base_import = function (instance) { {name: 'quoting', label: _lt("Quoting:"), value: '"'} ], events: { + 'change .oe_import_grid input': 'import_dryrun', 'change input.oe_import_file': 'file_update', 'change input.oe_import_has_header, .oe_import_options input': 'settings_updated', 'click a.oe_import_csv': function (e) { @@ -147,6 +148,7 @@ openerp.base_import = function (instance) { .text(item.label)); } }; + this.import_dryrun(); }, generate_fields_completion: function (root) { // Display nice names to user, but send logical names to server @@ -210,27 +212,35 @@ openerp.base_import = function (instance) { }, //- import itself - do_import: function () { + call_import: function (options) { var self = this; var fields = this.$('.oe_import_fields input').map(function (index, el) { // Convert user-readable field label to logical db-backed name return self.field_label_to_name[el.value] || false; }).get(); - this.Import.call( - 'do', [this.id, fields, this.import_options()], { - // maybe could do a dryrun after successful - // preview or something (note: don't go to - // this.result if dryrun=true) - dryrun: false - }) - .then(this.proxy('result')); + return this.Import.call( + 'do', [this.id, fields, this.import_options()], options); }, - result: function (errors) { - if (!errors.length) { - if (this.getParent().reload_content) { - this.getParent().reload_content(); + import_dryrun: function () { + this.call_import({ dryrun: true }) + .then(this.proxy('render_import_errors')); + }, + do_import: function () { + var self = this; + this.call_import({ dryrun: false }).then(function (errors) { + if (_.isEmpty(errors)) { + if (self.getParent().reload_content) { + self.getParent().reload_content(); + } + self.close(); + return; } - this.close(); + self.render_import_errors(errors); + }); + }, + render_import_errors: function (errors) { + if (_.isEmpty(errors)) { + this.$el.removeClass('oe_import_error'); return; } // import failed (or maybe just warnings, if we ever get From 38b913a3eb70668819f1eb611e828a50a150757e Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 3 Sep 2012 16:22:48 +0200 Subject: [PATCH 21/25] [FIX] comment out validation dryrun import: not for phase 1 bzr revid: xmo@openerp.com-20120903142248-v5oti2gltew9f674 --- addons/base_import/static/src/js/import.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index ca0f7e04ea7..2ec0caf1fc0 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -222,8 +222,8 @@ openerp.base_import = function (instance) { 'do', [this.id, fields, this.import_options()], options); }, import_dryrun: function () { - this.call_import({ dryrun: true }) - .then(this.proxy('render_import_errors')); +// this.call_import({ dryrun: true }) +// .then(this.proxy('render_import_errors')); }, do_import: function () { var self = this; From ad670b6887097b14888b8f960f03ae1f64e6150b Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 6 Sep 2012 12:35:16 +0200 Subject: [PATCH 22/25] [FIX] field-selection UI in import table: replace jquery-autocomplete by select2 * jQuery autocomplete requires complex hacks to work out and look select-ish * select2 is more straightforward to use for people expecting element."); + } + }); + } + + opts = $.extend({}, { + populateResults: function(container, results, query) { + var populate, data, result, children, id=this.opts.id, self=this; + + populate=function(results, container, depth) { + + var i, l, result, selectable, compound, node, label, innerContainer, formatted; + for (i = 0, l = results.length; i < l; i = i + 1) { + + result=results[i]; + selectable=id(result) !== undefined; + compound=("children" in result) && result.children.length > 0; + + node=$("
  • "); + node.addClass("select2-results-dept-"+depth); + node.addClass("select2-result"); + node.addClass(selectable ? "select2-result-selectable" : "select2-result-unselectable"); + if (compound) { node.addClass("select2-result-with-children"); } + node.addClass(self.opts.formatResultCssClass(result)); + + label=$("
    "); + label.addClass("select2-result-label"); + + formatted=opts.formatResult(result, label, query); + if (formatted!==undefined) { + label.html(self.opts.escapeMarkup(formatted)); + } + + node.append(label); + + if (compound) { + + innerContainer=$("
      "); + innerContainer.addClass("select2-result-sub"); + populate(result.children, innerContainer, depth+1); + node.append(innerContainer); + } + + node.data("select2-data", result); + container.append(node); + } + }; + + populate(results, container, 0); + } + }, $.fn.select2.defaults, opts); + + if (typeof(opts.id) !== "function") { + idKey = opts.id; + opts.id = function (e) { return e[idKey]; }; + } + + if (select) { + opts.query = this.bind(function (query) { + var data = { results: [], more: false }, + term = query.term, + children, firstChild, process; + + process=function(element, collection) { + var group; + if (element.is("option")) { + if (query.matcher(term, element.text(), element)) { + collection.push({id:element.attr("value"), text:element.text(), element: element.get(), css: element.attr("class")}); + } + } else if (element.is("optgroup")) { + group={text:element.attr("label"), children:[], element: element.get(), css: element.attr("class")}; + element.children().each2(function(i, elm) { process(elm, group.children); }); + if (group.children.length>0) { + collection.push(group); + } + } + }; + + children=element.children(); + + // ignore the placeholder option if there is one + if (this.getPlaceholder() !== undefined && children.length > 0) { + firstChild = children[0]; + if ($(firstChild).text() === "") { + children=children.not(firstChild); + } + } + + children.each2(function(i, elm) { process(elm, data.results); }); + + query.callback(data); + }); + // this is needed because inside val() we construct choices from options and there id is hardcoded + opts.id=function(e) { return e.id; }; + opts.formatResultCssClass = function(data) { return data.css; } + } else { + if (!("query" in opts)) { + if ("ajax" in opts) { + ajaxUrl = opts.element.data("ajax-url"); + if (ajaxUrl && ajaxUrl.length > 0) { + opts.ajax.url = ajaxUrl; + } + opts.query = ajax(opts.ajax); + } else if ("data" in opts) { + opts.query = local(opts.data); + } else if ("tags" in opts) { + opts.query = tags(opts.tags); + opts.createSearchChoice = function (term) { return {id: term, text: term}; }; + opts.initSelection = function (element, callback) { + var data = []; + $(splitVal(element.val(), opts.separator)).each(function () { + var id = this, text = this, tags=opts.tags; + if ($.isFunction(tags)) tags=tags(); + $(tags).each(function() { if (equal(this.id, id)) { text = this.text; return false; } }); + data.push({id: id, text: text}); + }); + + callback(data); + }; + } + } + } + if (typeof(opts.query) !== "function") { + throw "query function not defined for Select2 " + opts.element.attr("id"); + } + + return opts; + }, + + /** + * Monitor the original element for changes and update select2 accordingly + */ + // abstract + monitorSource: function () { + this.opts.element.bind("change.select2", this.bind(function (e) { + if (this.opts.element.data("select2-change-triggered") !== true) { + this.initSelection(); + } + })); + }, + + /** + * Triggers the change event on the source element + */ + // abstract + triggerChange: function (details) { + + details = details || {}; + details= $.extend({}, details, { type: "change", val: this.val() }); + // prevents recursive triggering + this.opts.element.data("select2-change-triggered", true); + this.opts.element.trigger(details); + this.opts.element.data("select2-change-triggered", false); + + // some validation frameworks ignore the change event and listen instead to keyup, click for selects + // so here we trigger the click event manually + this.opts.element.click(); + + // ValidationEngine ignorea the change event and listens instead to blur + // so here we trigger the blur event manually if so desired + if (this.opts.blurOnChange) + this.opts.element.blur(); + }, + + + // abstract + enable: function() { + if (this.enabled) return; + + this.enabled=true; + this.container.removeClass("select2-container-disabled"); + }, + + // abstract + disable: function() { + if (!this.enabled) return; + + this.close(); + + this.enabled=false; + this.container.addClass("select2-container-disabled"); + }, + + // abstract + opened: function () { + return this.container.hasClass("select2-dropdown-open"); + }, + + // abstract + positionDropdown: function() { + var offset = this.container.offset(), + height = this.container.outerHeight(), + width = this.container.outerWidth(), + dropHeight = this.dropdown.outerHeight(), + viewportBottom = $(window).scrollTop() + document.documentElement.clientHeight, + dropTop = offset.top + height, + dropLeft = offset.left, + enoughRoomBelow = dropTop + dropHeight <= viewportBottom, + enoughRoomAbove = (offset.top - dropHeight) >= this.body().scrollTop(), + aboveNow = this.dropdown.hasClass("select2-drop-above"), + bodyOffset, + above, + css; + + // console.log("below/ droptop:", dropTop, "dropHeight", dropHeight, "sum", (dropTop+dropHeight)+" viewport bottom", viewportBottom, "enough?", enoughRoomBelow); + // console.log("above/ offset.top", offset.top, "dropHeight", dropHeight, "top", (offset.top-dropHeight), "scrollTop", this.body().scrollTop(), "enough?", enoughRoomAbove); + + // fix positioning when body has an offset and is not position: static + + if (this.body().css('position') !== 'static') { + bodyOffset = this.body().offset(); + dropTop -= bodyOffset.top; + dropLeft -= bodyOffset.left; + } + + // always prefer the current above/below alignment, unless there is not enough room + + if (aboveNow) { + above = true; + if (!enoughRoomAbove && enoughRoomBelow) above = false; + } else { + above = false; + if (!enoughRoomBelow && enoughRoomAbove) above = true; + } + + if (above) { + dropTop = offset.top - dropHeight; + this.container.addClass("select2-drop-above"); + this.dropdown.addClass("select2-drop-above"); + } + else { + this.container.removeClass("select2-drop-above"); + this.dropdown.removeClass("select2-drop-above"); + } + + css = $.extend({ + top: dropTop, + left: dropLeft, + width: width + }, evaluate(this.opts.dropdownCss)); + + this.dropdown.css(css); + }, + + // abstract + shouldOpen: function() { + var event; + + if (this.opened()) return false; + + event = jQuery.Event("open"); + this.opts.element.trigger(event); + return !event.isDefaultPrevented(); + }, + + // abstract + clearDropdownAlignmentPreference: function() { + // clear the classes used to figure out the preference of where the dropdown should be opened + this.container.removeClass("select2-drop-above"); + this.dropdown.removeClass("select2-drop-above"); + }, + + /** + * Opens the dropdown + * + * @return {Boolean} whether or not dropdown was opened. This method will return false if, for example, + * the dropdown is already open, or if the 'open' event listener on the element called preventDefault(). + */ + // abstract + open: function () { + + if (!this.shouldOpen()) return false; + + window.setTimeout(this.bind(this.opening), 1); + + return true; + }, + + /** + * Performs the opening of the dropdown + */ + // abstract + opening: function() { + var cid = this.containerId, selector = this.containerSelector, + scroll = "scroll." + cid, resize = "resize." + cid; + + this.container.parents().each(function() { + $(this).bind(scroll, function() { + var s2 = $(selector); + if (s2.length == 0) { + $(this).unbind(scroll); + } + s2.select2("close"); + }); + }); + + $(window).bind(resize, function() { + var s2 = $(selector); + if (s2.length == 0) { + $(window).unbind(resize); + } + s2.select2("close"); + }); + + this.clearDropdownAlignmentPreference(); + + if (this.search.val() === " ") { this.search.val(""); } + + this.container.addClass("select2-dropdown-open").addClass("select2-container-active"); + + this.updateResults(true); + + if(this.dropdown[0] !== this.body().children().last()[0]) { + this.dropdown.detach().appendTo(this.body()); + } + + this.dropdown.show(); + + this.positionDropdown(); + this.dropdown.addClass("select2-drop-active"); + + this.ensureHighlightVisible(); + + this.focusSearch(); + }, + + // abstract + close: function () { + if (!this.opened()) return; + + var self = this; + + this.container.parents().each(function() { + $(this).unbind("scroll." + self.containerId); + }); + $(window).unbind("resize." + this.containerId); + + this.clearDropdownAlignmentPreference(); + + this.dropdown.hide(); + this.container.removeClass("select2-dropdown-open").removeClass("select2-container-active"); + this.results.empty(); + this.clearSearch(); + + this.opts.element.trigger(jQuery.Event("close")); + }, + + // abstract + clearSearch: function () { + + }, + + // abstract + ensureHighlightVisible: function () { + var results = this.results, children, index, child, hb, rb, y, more; + + index = this.highlight(); + + if (index < 0) return; + + if (index == 0) { + + // if the first element is highlighted scroll all the way to the top, + // that way any unselectable headers above it will also be scrolled + // into view + + results.scrollTop(0); + return; + } + + children = results.find(".select2-result-selectable"); + + child = $(children[index]); + + hb = child.offset().top + child.outerHeight(); + + // if this is the last child lets also make sure select2-more-results is visible + if (index === children.length - 1) { + more = results.find("li.select2-more-results"); + if (more.length > 0) { + hb = more.offset().top + more.outerHeight(); + } + } + + rb = results.offset().top + results.outerHeight(); + if (hb > rb) { + results.scrollTop(results.scrollTop() + (hb - rb)); + } + y = child.offset().top - results.offset().top; + + // make sure the top of the element is visible + if (y < 0) { + results.scrollTop(results.scrollTop() + y); // y is negative + } + }, + + // abstract + moveHighlight: function (delta) { + var choices = this.results.find(".select2-result-selectable"), + index = this.highlight(); + + while (index > -1 && index < choices.length) { + index += delta; + var choice = $(choices[index]); + if (choice.hasClass("select2-result-selectable") && !choice.hasClass("select2-disabled")) { + this.highlight(index); + break; + } + } + }, + + // abstract + highlight: function (index) { + var choices = this.results.find(".select2-result-selectable").not(".select2-disabled"); + + if (arguments.length === 0) { + return indexOf(choices.filter(".select2-highlighted")[0], choices.get()); + } + + if (index >= choices.length) index = choices.length - 1; + if (index < 0) index = 0; + + choices.removeClass("select2-highlighted"); + + $(choices[index]).addClass("select2-highlighted"); + this.ensureHighlightVisible(); + + }, + + // abstract + countSelectableResults: function() { + return this.results.find(".select2-result-selectable").not(".select2-disabled").length; + }, + + // abstract + highlightUnderEvent: function (event) { + var el = $(event.target).closest(".select2-result-selectable"); + if (el.length > 0 && !el.is(".select2-highlighted")) { + var choices = this.results.find('.select2-result-selectable'); + this.highlight(choices.index(el)); + } else if (el.length == 0) { + // if we are over an unselectable item remove al highlights + this.results.find(".select2-highlighted").removeClass("select2-highlighted"); + } + }, + + // abstract + loadMoreIfNeeded: function () { + var results = this.results, + more = results.find("li.select2-more-results"), + below, // pixels the element is below the scroll fold, below==0 is when the element is starting to be visible + offset = -1, // index of first element without data + page = this.resultsPage + 1, + self=this, + term=this.search.val(), + context=this.context; + + if (more.length === 0) return; + below = more.offset().top - results.offset().top - results.height(); + + if (below <= 0) { + more.addClass("select2-active"); + this.opts.query({ + term: term, + page: page, + context: context, + matcher: this.opts.matcher, + callback: this.bind(function (data) { + + // ignore a response if the select2 has been closed before it was received + if (!self.opened()) return; + + + self.opts.populateResults.call(this, results, data.results, {term: term, page: page, context:context}); + + if (data.more===true) { + more.detach().appendTo(results).text(self.opts.formatLoadMore(page+1)); + window.setTimeout(function() { self.loadMoreIfNeeded(); }, 10); + } else { + more.remove(); + } + self.positionDropdown(); + self.resultsPage = page; + })}); + } + }, + + /** + * Default tokenizer function which does nothing + */ + tokenize: function() { + + }, + + /** + * @param initial whether or not this is the call to this method right after the dropdown has been opened + */ + // abstract + updateResults: function (initial) { + var search = this.search, results = this.results, opts = this.opts, data, self=this, input; + + // if the search is currently hidden we do not alter the results + if (initial !== true && (this.showSearchInput === false || !this.opened())) { + return; + } + + search.addClass("select2-active"); + + function postRender() { + results.scrollTop(0); + search.removeClass("select2-active"); + self.positionDropdown(); + } + + function render(html) { + results.html(self.opts.escapeMarkup(html)); + postRender(); + } + + if (opts.maximumSelectionSize >=1) { + data = this.data(); + if ($.isArray(data) && data.length >= opts.maximumSelectionSize && checkFormatter(opts.formatSelectionTooBig, "formatSelectionTooBig")) { + render("
    • " + opts.formatSelectionTooBig(opts.maximumSelectionSize) + "
    • "); + return; + } + } + + if (search.val().length < opts.minimumInputLength && checkFormatter(opts.formatInputTooShort, "formatInputTooShort")) { + render("
    • " + opts.formatInputTooShort(search.val(), opts.minimumInputLength) + "
    • "); + return; + } + else { + render("
    • " + opts.formatSearching() + "
    • "); + } + + // give the tokenizer a chance to pre-process the input + input = this.tokenize(); + if (input != undefined && input != null) { + search.val(input); + } + + this.resultsPage = 1; + opts.query({ + term: search.val(), + page: this.resultsPage, + context: null, + matcher: opts.matcher, + callback: this.bind(function (data) { + var def; // default choice + + // ignore a response if the select2 has been closed before it was received + if (!this.opened()) return; + + // save context, if any + this.context = (data.context===undefined) ? null : data.context; + + // create a default choice and prepend it to the list + if (this.opts.createSearchChoice && search.val() !== "") { + def = this.opts.createSearchChoice.call(null, search.val(), data.results); + if (def !== undefined && def !== null && self.id(def) !== undefined && self.id(def) !== null) { + if ($(data.results).filter( + function () { + return equal(self.id(this), self.id(def)); + }).length === 0) { + data.results.unshift(def); + } + } + } + + if (data.results.length === 0 && checkFormatter(opts.formatNoMatches, "formatNoMatches")) { + render("
    • " + opts.formatNoMatches(search.val()) + "
    • "); + return; + } + + results.empty(); + self.opts.populateResults.call(this, results, data.results, {term: search.val(), page: this.resultsPage, context:null}); + + if (data.more === true && checkFormatter(opts.formatLoadMore, "formatLoadMore")) { + results.append("
    • " + self.opts.escapeMarkup(opts.formatLoadMore(this.resultsPage)) + "
    • "); + window.setTimeout(function() { self.loadMoreIfNeeded(); }, 10); + } + + this.postprocessResults(data, initial); + + postRender(); + })}); + }, + + // abstract + cancel: function () { + this.close(); + }, + + // abstract + blur: function () { + this.close(); + this.container.removeClass("select2-container-active"); + this.dropdown.removeClass("select2-drop-active"); + // synonymous to .is(':focus'), which is available in jquery >= 1.6 + if (this.search[0] === document.activeElement) { this.search.blur(); } + this.clearSearch(); + this.selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"); + }, + + // abstract + focusSearch: function () { + // need to do it here as well as in timeout so it works in IE + this.search.show(); + this.search.focus(); + + /* we do this in a timeout so that current event processing can complete before this code is executed. + this makes sure the search field is focussed even if the current event would blur it */ + window.setTimeout(this.bind(function () { + // reset the value so IE places the cursor at the end of the input box + this.search.show(); + this.search.focus(); + this.search.val(this.search.val()); + }), 10); + }, + + // abstract + selectHighlighted: function () { + var index=this.highlight(), + highlighted=this.results.find(".select2-highlighted").not(".select2-disabled"), + data = highlighted.closest('.select2-result-selectable').data("select2-data"); + if (data) { + highlighted.addClass("select2-disabled"); + this.highlight(index); + this.onSelect(data); + } + }, + + // abstract + getPlaceholder: function () { + return this.opts.element.attr("placeholder") || + this.opts.element.attr("data-placeholder") || // jquery 1.4 compat + this.opts.element.data("placeholder") || + this.opts.placeholder; + }, + + /** + * Get the desired width for the container element. This is + * derived first from option `width` passed to select2, then + * the inline 'style' on the original element, and finally + * falls back to the jQuery calculated element width. + */ + // abstract + initContainerWidth: function () { + function resolveContainerWidth() { + var style, attrs, matches, i, l; + + if (this.opts.width === "off") { + return null; + } else if (this.opts.width === "element"){ + return this.opts.element.outerWidth() === 0 ? 'auto' : this.opts.element.outerWidth() + 'px'; + } else if (this.opts.width === "copy" || this.opts.width === "resolve") { + // check if there is inline style on the element that contains width + style = this.opts.element.attr('style'); + if (style !== undefined) { + attrs = style.split(';'); + for (i = 0, l = attrs.length; i < l; i = i + 1) { + matches = attrs[i].replace(/\s/g, '') + .match(/width:(([-+]?([0-9]*\.)?[0-9]+)(px|em|ex|%|in|cm|mm|pt|pc))/); + if (matches !== null && matches.length >= 1) + return matches[1]; + } + } + + if (this.opts.width === "resolve") { + // next check if css('width') can resolve a width that is percent based, this is sometimes possible + // when attached to input type=hidden or elements hidden via css + style = this.opts.element.css('width'); + if (style.indexOf("%") > 0) return style; + + // finally, fallback on the calculated width of the element + return (this.opts.element.outerWidth() === 0 ? 'auto' : this.opts.element.outerWidth() + 'px'); + } + + return null; + } else if ($.isFunction(this.opts.width)) { + return this.opts.width(); + } else { + return this.opts.width; + } + }; + + var width = resolveContainerWidth.call(this); + if (width !== null) { + this.container.attr("style", "width: "+width); + } + } + }); + + SingleSelect2 = clazz(AbstractSelect2, { + + // single + + createContainer: function () { + var container = $("
      ", { + "class": "select2-container" + }).html([ + "
      ", + " ", + "
      " , + "
      ", + "
      " , + " " , + "
        " , + "
      " , + "
      "].join("")); + return container; + }, + + // single + opening: function () { + this.search.show(); + this.parent.opening.apply(this, arguments); + this.dropdown.removeClass("select2-offscreen"); + }, + + // single + close: function () { + if (!this.opened()) return; + this.parent.close.apply(this, arguments); + this.dropdown.removeAttr("style").addClass("select2-offscreen").insertAfter(this.selection).show(); + }, + + // single + focus: function () { + this.close(); + this.selection.focus(); + }, + + // single + isFocused: function () { + return this.selection[0] === document.activeElement; + }, + + // single + cancel: function () { + this.parent.cancel.apply(this, arguments); + this.selection.focus(); + }, + + // single + initContainer: function () { + + var selection, + container = this.container, + dropdown = this.dropdown, + clickingInside = false; + + this.selection = selection = container.find(".select2-choice"); + + this.search.bind("keydown", this.bind(function (e) { + if (!this.enabled) return; + + if (e.which === KEY.PAGE_UP || e.which === KEY.PAGE_DOWN) { + // prevent the page from scrolling + killEvent(e); + return; + } + + if (this.opened()) { + switch (e.which) { + case KEY.UP: + case KEY.DOWN: + this.moveHighlight((e.which === KEY.UP) ? -1 : 1); + killEvent(e); + return; + case KEY.TAB: + case KEY.ENTER: + this.selectHighlighted(); + killEvent(e); + return; + case KEY.ESC: + this.cancel(e); + killEvent(e); + return; + } + } else { + + if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) || e.which === KEY.ESC) { + return; + } + + if (this.opts.openOnEnter === false && e.which === KEY.ENTER) { + return; + } + + this.open(); + + if (e.which === KEY.ENTER) { + // do not propagate the event otherwise we open, and propagate enter which closes + return; + } + } + })); + + this.search.bind("focus", this.bind(function() { + this.selection.attr("tabIndex", "-1"); + })); + this.search.bind("blur", this.bind(function() { + if (!this.opened()) this.container.removeClass("select2-container-active"); + window.setTimeout(this.bind(function() { this.selection.attr("tabIndex", this.opts.element.attr("tabIndex")); }), 10); + })); + + selection.bind("mousedown", this.bind(function (e) { + clickingInside = true; + + if (this.opened()) { + this.close(); + this.selection.focus(); + } else if (this.enabled) { + this.open(); + } + + clickingInside = false; + })); + + dropdown.bind("mousedown", this.bind(function() { this.search.focus(); })); + + selection.bind("focus", this.bind(function() { + this.container.addClass("select2-container-active"); + // hide the search so the tab key does not focus on it + this.search.attr("tabIndex", "-1"); + })); + + selection.bind("blur", this.bind(function() { + if (!this.opened()) { + this.container.removeClass("select2-container-active"); + } + window.setTimeout(this.bind(function() { this.search.attr("tabIndex", this.opts.element.attr("tabIndex")); }), 10); + })); + + selection.bind("keydown", this.bind(function(e) { + if (!this.enabled) return; + + if (e.which === KEY.PAGE_UP || e.which === KEY.PAGE_DOWN) { + // prevent the page from scrolling + killEvent(e); + return; + } + + if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) + || e.which === KEY.ESC) { + return; + } + + if (this.opts.openOnEnter === false && e.which === KEY.ENTER) { + return; + } + + if (e.which == KEY.DELETE) { + if (this.opts.allowClear) { + this.clear(); + } + return; + } + + this.open(); + + if (e.which === KEY.ENTER) { + // do not propagate the event otherwise we open, and propagate enter which closes + killEvent(e); + return; + } + + // do not set the search input value for non-alpha-numeric keys + // otherwise pressing down results in a '(' being set in the search field + if (e.which < 48 ) { // '0' == 48 + killEvent(e); + return; + } + + var keyWritten = String.fromCharCode(e.which).toLowerCase(); + + if (e.shiftKey) { + keyWritten = keyWritten.toUpperCase(); + } + + // focus the field before calling val so the cursor ends up after the value instead of before + this.search.focus(); + this.search.val(keyWritten); + + // prevent event propagation so it doesnt replay on the now focussed search field and result in double key entry + killEvent(e); + })); + + selection.delegate("abbr", "mousedown", this.bind(function (e) { + if (!this.enabled) return; + this.clear(); + killEvent(e); + this.close(); + this.triggerChange(); + this.selection.focus(); + })); + + this.setPlaceholder(); + + this.search.bind("focus", this.bind(function() { + this.container.addClass("select2-container-active"); + })); + }, + + // single + clear: function() { + this.opts.element.val(""); + this.selection.find("span").empty(); + this.selection.removeData("select2-data"); + this.setPlaceholder(); + }, + + /** + * Sets selection based on source element's value + */ + // single + initSelection: function () { + var selected; + if (this.opts.element.val() === "") { + this.close(); + this.setPlaceholder(); + } else { + var self = this; + this.opts.initSelection.call(null, this.opts.element, function(selected){ + if (selected !== undefined && selected !== null) { + self.updateSelection(selected); + self.close(); + self.setPlaceholder(); + } + }); + } + }, + + // single + prepareOpts: function () { + var opts = this.parent.prepareOpts.apply(this, arguments); + + if (opts.element.get(0).tagName.toLowerCase() === "select") { + // install the selection initializer + opts.initSelection = function (element, callback) { + var selected = element.find(":selected"); + // a single select box always has a value, no need to null check 'selected' + if ($.isFunction(callback)) + callback({id: selected.attr("value"), text: selected.text()}); + }; + } + + return opts; + }, + + // single + setPlaceholder: function () { + var placeholder = this.getPlaceholder(); + + if (this.opts.element.val() === "" && placeholder !== undefined) { + + // check for a first blank option if attached to a select + if (this.select && this.select.find("option:first").text() !== "") return; + + this.selection.find("span").html(this.opts.escapeMarkup(placeholder)); + + this.selection.addClass("select2-default"); + + this.selection.find("abbr").hide(); + } + }, + + // single + postprocessResults: function (data, initial) { + var selected = 0, self = this, showSearchInput = true; + + // find the selected element in the result list + + this.results.find(".select2-result-selectable").each2(function (i, elm) { + if (equal(self.id(elm.data("select2-data")), self.opts.element.val())) { + selected = i; + return false; + } + }); + + // and highlight it + + this.highlight(selected); + + // hide the search box if this is the first we got the results and there are a few of them + + if (initial === true) { + showSearchInput = this.showSearchInput = countResults(data.results) >= this.opts.minimumResultsForSearch; + this.dropdown.find(".select2-search")[showSearchInput ? "removeClass" : "addClass"]("select2-search-hidden"); + + //add "select2-with-searchbox" to the container if search box is shown + $(this.dropdown, this.container)[showSearchInput ? "addClass" : "removeClass"]("select2-with-searchbox"); + } + + }, + + // single + onSelect: function (data) { + var old = this.opts.element.val(); + + this.opts.element.val(this.id(data)); + this.updateSelection(data); + this.close(); + this.selection.focus(); + + if (!equal(old, this.id(data))) { this.triggerChange(); } + }, + + // single + updateSelection: function (data) { + + var container=this.selection.find("span"), formatted; + + this.selection.data("select2-data", data); + + container.empty(); + formatted=this.opts.formatSelection(data, container); + if (formatted !== undefined) { + container.append(this.opts.escapeMarkup(formatted)); + } + + this.selection.removeClass("select2-default"); + + if (this.opts.allowClear && this.getPlaceholder() !== undefined) { + this.selection.find("abbr").show(); + } + }, + + // single + val: function () { + var val, data = null, self = this; + + if (arguments.length === 0) { + return this.opts.element.val(); + } + + val = arguments[0]; + + if (this.select) { + this.select + .val(val) + .find(":selected").each2(function (i, elm) { + data = {id: elm.attr("value"), text: elm.text()}; + return false; + }); + this.updateSelection(data); + this.setPlaceholder(); + } else { + if (this.opts.initSelection === undefined) { + throw new Error("cannot call val() if initSelection() is not defined"); + } + // val is an id. !val is true for [undefined,null,''] + if (!val) { + this.clear(); + return; + } + this.opts.element.val(val); + this.opts.initSelection(this.opts.element, function(data){ + self.opts.element.val(!data ? "" : self.id(data)); + self.updateSelection(data); + self.setPlaceholder(); + }); + } + }, + + // single + clearSearch: function () { + this.search.val(""); + }, + + // single + data: function(value) { + var data; + + if (arguments.length === 0) { + data = this.selection.data("select2-data"); + if (data == undefined) data = null; + return data; + } else { + if (!value || value === "") { + this.clear(); + } else { + this.opts.element.val(!value ? "" : this.id(value)); + this.updateSelection(value); + } + } + } + }); + + MultiSelect2 = clazz(AbstractSelect2, { + + // multi + createContainer: function () { + var container = $("
      ", { + "class": "select2-container select2-container-multi" + }).html([ + "
        ", + //"
      • California
      • " , + "
      • " , + " " , + "
      • " , + "
      " , + ""].join("")); + return container; + }, + + // multi + prepareOpts: function () { + var opts = this.parent.prepareOpts.apply(this, arguments); + + // TODO validate placeholder is a string if specified + + if (opts.element.get(0).tagName.toLowerCase() === "select") { + // install sthe selection initializer + opts.initSelection = function (element,callback) { + + var data = []; + element.find(":selected").each2(function (i, elm) { + data.push({id: elm.attr("value"), text: elm.text()}); + }); + + if ($.isFunction(callback)) + callback(data); + }; + } + + return opts; + }, + + // multi + initContainer: function () { + + var selector = ".select2-choices", selection; + + this.searchContainer = this.container.find(".select2-search-field"); + this.selection = selection = this.container.find(selector); + + this.search.bind("keydown", this.bind(function (e) { + if (!this.enabled) return; + + if (e.which === KEY.BACKSPACE && this.search.val() === "") { + this.close(); + + var choices, + selected = selection.find(".select2-search-choice-focus"); + if (selected.length > 0) { + this.unselect(selected.first()); + this.search.width(10); + killEvent(e); + return; + } + + choices = selection.find(".select2-search-choice"); + if (choices.length > 0) { + choices.last().addClass("select2-search-choice-focus"); + } + } else { + selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"); + } + + if (this.opened()) { + switch (e.which) { + case KEY.UP: + case KEY.DOWN: + this.moveHighlight((e.which === KEY.UP) ? -1 : 1); + killEvent(e); + return; + case KEY.ENTER: + case KEY.TAB: + this.selectHighlighted(); + killEvent(e); + return; + case KEY.ESC: + this.cancel(e); + killEvent(e); + return; + } + } + + if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) + || e.which === KEY.BACKSPACE || e.which === KEY.ESC) { + return; + } + + if (this.opts.openOnEnter === false && e.which === KEY.ENTER) { + return; + } + + this.open(); + + if (e.which === KEY.PAGE_UP || e.which === KEY.PAGE_DOWN) { + // prevent the page from scrolling + killEvent(e); + } + })); + + this.search.bind("keyup", this.bind(this.resizeSearch)); + + this.search.bind("blur", this.bind(function(e) { + this.container.removeClass("select2-container-active"); + this.search.removeClass("select2-focused"); + this.clearSearch(); + e.stopImmediatePropagation(); + })); + + this.container.delegate(selector, "mousedown", this.bind(function (e) { + if (!this.enabled) return; + if ($(e.target).closest(".select2-search-choice").length > 0) { + // clicked inside a select2 search choice, do not open + return; + } + this.clearPlaceholder(); + this.open(); + this.focusSearch(); + e.preventDefault(); + })); + + this.container.delegate(selector, "focus", this.bind(function () { + if (!this.enabled) return; + this.container.addClass("select2-container-active"); + this.dropdown.addClass("select2-drop-active"); + this.clearPlaceholder(); + })); + + // set the placeholder if necessary + this.clearSearch(); + }, + + // multi + enable: function() { + if (this.enabled) return; + + this.parent.enable.apply(this, arguments); + + this.search.removeAttr("disabled"); + }, + + // multi + disable: function() { + if (!this.enabled) return; + + this.parent.disable.apply(this, arguments); + + this.search.attr("disabled", true); + }, + + // multi + initSelection: function () { + var data; + if (this.opts.element.val() === "") { + this.updateSelection([]); + this.close(); + // set the placeholder if necessary + this.clearSearch(); + } + if (this.select || this.opts.element.val() !== "") { + var self = this; + this.opts.initSelection.call(null, this.opts.element, function(data){ + if (data !== undefined && data !== null) { + self.updateSelection(data); + self.close(); + // set the placeholder if necessary + self.clearSearch(); + } + }); + } + }, + + // multi + clearSearch: function () { + var placeholder = this.getPlaceholder(); + + if (placeholder !== undefined && this.getVal().length === 0 && this.search.hasClass("select2-focused") === false) { + this.search.val(placeholder).addClass("select2-default"); + // stretch the search box to full width of the container so as much of the placeholder is visible as possible + this.resizeSearch(); + } else { + // we set this to " " instead of "" and later clear it on focus() because there is a firefox bug + // that does not properly render the caret when the field starts out blank + this.search.val(" ").width(10); + } + }, + + // multi + clearPlaceholder: function () { + if (this.search.hasClass("select2-default")) { + this.search.val("").removeClass("select2-default"); + } else { + // work around for the space character we set to avoid firefox caret bug + if (this.search.val() === " ") this.search.val(""); + } + }, + + // multi + opening: function () { + this.parent.opening.apply(this, arguments); + + this.clearPlaceholder(); + this.resizeSearch(); + this.focusSearch(); + }, + + // multi + close: function () { + if (!this.opened()) return; + this.parent.close.apply(this, arguments); + }, + + // multi + focus: function () { + this.close(); + this.search.focus(); + }, + + // multi + isFocused: function () { + return this.search.hasClass("select2-focused"); + }, + + // multi + updateSelection: function (data) { + var ids = [], filtered = [], self = this; + + // filter out duplicates + $(data).each(function () { + if (indexOf(self.id(this), ids) < 0) { + ids.push(self.id(this)); + filtered.push(this); + } + }); + data = filtered; + + this.selection.find(".select2-search-choice").remove(); + $(data).each(function () { + self.addSelectedChoice(this); + }); + self.postprocessResults(); + }, + + tokenize: function() { + var input = this.search.val(); + input = this.opts.tokenizer(input, this.data(), this.bind(this.onSelect), this.opts); + if (input != null && input != undefined) { + this.search.val(input); + if (input.length > 0) { + this.open(); + } + } + + }, + + // multi + onSelect: function (data) { + this.addSelectedChoice(data); + if (this.select) { this.postprocessResults(); } + + if (this.opts.closeOnSelect) { + this.close(); + this.search.width(10); + } else { + if (this.countSelectableResults()>0) { + this.search.width(10); + this.resizeSearch(); + this.positionDropdown(); + } else { + // if nothing left to select close + this.close(); + } + } + + // since its not possible to select an element that has already been + // added we do not need to check if this is a new element before firing change + this.triggerChange({ added: data }); + + this.focusSearch(); + }, + + // multi + cancel: function () { + this.close(); + this.focusSearch(); + }, + + // multi + addSelectedChoice: function (data) { + var choice=$( + "
    • " + + "
      " + + " " + + "
    • "), + id = this.id(data), + val = this.getVal(), + formatted; + + formatted=this.opts.formatSelection(data, choice); + choice.find("div").replaceWith("
      "+this.opts.escapeMarkup(formatted)+"
      "); + choice.find(".select2-search-choice-close") + .bind("mousedown", killEvent) + .bind("click dblclick", this.bind(function (e) { + if (!this.enabled) return; + + $(e.target).closest(".select2-search-choice").fadeOut('fast', this.bind(function(){ + this.unselect($(e.target)); + this.selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"); + this.close(); + this.focusSearch(); + })).dequeue(); + killEvent(e); + })).bind("focus", this.bind(function () { + if (!this.enabled) return; + this.container.addClass("select2-container-active"); + this.dropdown.addClass("select2-drop-active"); + })); + + choice.data("select2-data", data); + choice.insertBefore(this.searchContainer); + + val.push(id); + this.setVal(val); + }, + + // multi + unselect: function (selected) { + var val = this.getVal(), + data, + index; + + selected = selected.closest(".select2-search-choice"); + + if (selected.length === 0) { + throw "Invalid argument: " + selected + ". Must be .select2-search-choice"; + } + + data = selected.data("select2-data"); + + index = indexOf(this.id(data), val); + + if (index >= 0) { + val.splice(index, 1); + this.setVal(val); + if (this.select) this.postprocessResults(); + } + selected.remove(); + this.triggerChange({ removed: data }); + }, + + // multi + postprocessResults: function () { + var val = this.getVal(), + choices = this.results.find(".select2-result-selectable"), + compound = this.results.find(".select2-result-with-children"), + self = this; + + choices.each2(function (i, choice) { + var id = self.id(choice.data("select2-data")); + if (indexOf(id, val) >= 0) { + choice.addClass("select2-disabled").removeClass("select2-result-selectable"); + } else { + choice.removeClass("select2-disabled").addClass("select2-result-selectable"); + } + }); + + compound.each2(function(i, e) { + if (e.find(".select2-result-selectable").length==0) { + e.addClass("select2-disabled"); + } else { + e.removeClass("select2-disabled"); + } + }); + + choices.each2(function (i, choice) { + if (!choice.hasClass("select2-disabled") && choice.hasClass("select2-result-selectable")) { + self.highlight(0); + return false; + } + }); + + }, + + // multi + resizeSearch: function () { + + var minimumWidth, left, maxWidth, containerLeft, searchWidth, + sideBorderPadding = getSideBorderPadding(this.search); + + minimumWidth = measureTextWidth(this.search) + 10; + + left = this.search.offset().left; + + maxWidth = this.selection.width(); + containerLeft = this.selection.offset().left; + + searchWidth = maxWidth - (left - containerLeft) - sideBorderPadding; + if (searchWidth < minimumWidth) { + searchWidth = maxWidth - sideBorderPadding; + } + + if (searchWidth < 40) { + searchWidth = maxWidth - sideBorderPadding; + } + this.search.width(searchWidth); + }, + + // multi + getVal: function () { + var val; + if (this.select) { + val = this.select.val(); + return val === null ? [] : val; + } else { + val = this.opts.element.val(); + return splitVal(val, this.opts.separator); + } + }, + + // multi + setVal: function (val) { + var unique; + if (this.select) { + this.select.val(val); + } else { + unique = []; + // filter out duplicates + $(val).each(function () { + if (indexOf(this, unique) < 0) unique.push(this); + }); + this.opts.element.val(unique.length === 0 ? "" : unique.join(this.opts.separator)); + } + }, + + // multi + val: function () { + var val, data = [], self=this; + + if (arguments.length === 0) { + return this.getVal(); + } + + val = arguments[0]; + + if (!val) { + this.opts.element.val(""); + this.updateSelection([]); + this.clearSearch(); + return; + } + + // val is a list of ids + this.setVal(val); + + if (this.select) { + this.select.find(":selected").each(function () { + data.push({id: $(this).attr("value"), text: $(this).text()}); + }); + this.updateSelection(data); + } else { + if (this.opts.initSelection === undefined) { + throw new Error("val() cannot be called if initSelection() is not defined") + } + + this.opts.initSelection(this.opts.element, function(data){ + var ids=$(data).map(self.id); + self.setVal(ids); + self.updateSelection(data); + self.clearSearch(); + }); + } + this.clearSearch(); + }, + + // multi + onSortStart: function() { + if (this.select) { + throw new Error("Sorting of elements is not supported when attached to instead."); + } + + // collapse search field into 0 width so its container can be collapsed as well + this.search.width(0); + // hide the container + this.searchContainer.hide(); + }, + + // multi + onSortEnd:function() { + + var val=[], self=this; + + // show search and move it to the end of the list + this.searchContainer.show(); + // make sure the search container is the last item in the list + this.searchContainer.appendTo(this.searchContainer.parent()); + // since we collapsed the width in dragStarted, we resize it here + this.resizeSearch(); + + // update selection + + this.selection.find(".select2-search-choice").each(function() { + val.push(self.opts.id($(this).data("select2-data"))); + }); + this.setVal(val); + this.triggerChange(); + }, + + // multi + data: function(values) { + var self=this, ids; + if (arguments.length === 0) { + return this.selection + .find(".select2-search-choice") + .map(function() { return $(this).data("select2-data"); }) + .get(); + } else { + if (!values) { values = []; } + ids = $.map(values, function(e) { return self.opts.id(e)}); + this.setVal(ids); + this.updateSelection(values); + this.clearSearch(); + } + } + }); + + $.fn.select2 = function () { + + var args = Array.prototype.slice.call(arguments, 0), + opts, + select2, + value, multiple, allowedMethods = ["val", "destroy", "opened", "open", "close", "focus", "isFocused", "container", "onSortStart", "onSortEnd", "enable", "disable", "positionDropdown", "data"]; + + this.each(function () { + if (args.length === 0 || typeof(args[0]) === "object") { + opts = args.length === 0 ? {} : $.extend({}, args[0]); + opts.element = $(this); + + if (opts.element.get(0).tagName.toLowerCase() === "select") { + multiple = opts.element.attr("multiple"); + } else { + multiple = opts.multiple || false; + if ("tags" in opts) {opts.multiple = multiple = true;} + } + + select2 = multiple ? new MultiSelect2() : new SingleSelect2(); + select2.init(opts); + } else if (typeof(args[0]) === "string") { + + if (indexOf(args[0], allowedMethods) < 0) { + throw "Unknown method: " + args[0]; + } + + value = undefined; + select2 = $(this).data("select2"); + if (select2 === undefined) return; + if (args[0] === "container") { + value=select2.container; + } else { + value = select2[args[0]].apply(select2, args.slice(1)); + } + if (value !== undefined) {return false;} + } else { + throw "Invalid arguments to select2 plugin: " + args; + } + }); + return (value === undefined) ? this : value; + }; + + // plugin defaults, accessible to users + $.fn.select2.defaults = { + width: "copy", + closeOnSelect: true, + openOnEnter: true, + containerCss: {}, + dropdownCss: {}, + containerCssClass: "", + dropdownCssClass: "", + formatResult: function(result, container, query) { + var markup=[]; + markMatch(result.text, query.term, markup); + return markup.join(""); + }, + formatSelection: function (data, container) { + return data.text; + }, + formatResultCssClass: function(data) {return undefined;}, + formatNoMatches: function () { return "No matches found"; }, + formatInputTooShort: function (input, min) { return "Please enter " + (min - input.length) + " more characters"; }, + formatSelectionTooBig: function (limit) { return "You can only select " + limit + " item" + (limit == 1 ? "" : "s"); }, + formatLoadMore: function (pageNumber) { return "Loading more results..."; }, + formatSearching: function () { return "Searching..."; }, + minimumResultsForSearch: 0, + minimumInputLength: 0, + maximumSelectionSize: 0, + id: function (e) { return e.id; }, + matcher: function(term, text) { + return text.toUpperCase().indexOf(term.toUpperCase()) >= 0; + }, + separator: ",", + tokenSeparators: [], + tokenizer: defaultTokenizer, + escapeMarkup: function (markup) { + if (markup && typeof(markup) === "string") { + return markup.replace(/&/g, "&"); + } + return markup; + }, + blurOnChange: false + }; + + // exports + window.Select2 = { + query: { + ajax: ajax, + local: local, + tags: tags + }, util: { + debounce: debounce, + markMatch: markMatch + }, "class": { + "abstract": AbstractSelect2, + "single": SingleSelect2, + "multi": MultiSelect2 + } + }; + +}(jQuery)); diff --git a/addons/base_import/static/lib/select2/select2.png b/addons/base_import/static/lib/select2/select2.png new file mode 100644 index 0000000000000000000000000000000000000000..1d804ffb99699b9e030f1010314de0970b5a000d GIT binary patch literal 613 zcmV-r0-F7aP)#WY!I$JQV$)A5aAS1BM||2XVJl=+L1^1S1H% zM-&lx?NZpUrHhn>fk<>POqf2sh40}xxGZfc+t+#Eb(qHy9_3*1(U%t9t)QDnI#YAL(|ACV(>)>6WD-t!8tutHkdb^#3`HzoJG3A2@T`% zA|K@o*b!`R#(7)PWrMFn2))Ca3MR4(zaT`Zr61*kZK5NPnZwQszxh$fyv3?&4c>$q z2m=+yc0dRXRAsPDxF6sD;@rK4JGdR_``1S~o6Xi@2&aR6hcSrEp9HVRzEqVDqBn<1%hR=D4e1f^ra^A|34Cjc=Gny{F(o#MrvPYgZuTJOz(n)-F<| zj()qR;C={)N<0RRvDZ^@6ND+W*}gh-Lip(MDt!(zMSO)!j2j+*hxgzC-e3$@(O2p* zu;+gddm(cZwXTCLx*Ky4THOa*^b^F`woveIeCK^0aR|TJ00000NkvXXu0mjfA#WC6 literal 0 HcmV?d00001 diff --git a/addons/base_import/static/lib/select2/select2x2.png b/addons/base_import/static/lib/select2/select2x2.png new file mode 100644 index 0000000000000000000000000000000000000000..4bdd5c961d452c49dfa0789c2c7ffb82c238fc24 GIT binary patch literal 845 zcmV-T1G4;yP)upQ6WKflyv?C|ADVW!U!t`EpA+x zB)5#EjWk-_X77YJZtQo`E0SF)^1bZr%)B7Cd`*OK*r z5WG-7e-R9G9^69ksDt29&oyHqxPSt|-S>xi3%PTd+GjY+BGF|nWC(7D-sd(kxqd9~ zS@2YF5vB+>dP8+$l^{oO3-lEWiGA*QIU)Wds#9M6RZ9N zcQ4y4)xqQOxD=vwu%7cz1nY#$lT&y8HCmkWgpwQP#3dhnYj9|2aS_R}IUF_^6s#$= zTm%~>A#oM?KIg$kh=<`gJkeoHa2LrulVy$Yx+N_0R3$4I!R*0677f(FKqm`2_o4~W z0h}fQZ`lC^1A+m;fM7uI(R1`S0KtG@KrkQ}5DW+&@cTnDVIow56KciMk7a899t0bC zC1KI{TsMe5NAR%GD_5`B-@ad4k~K3SO%H z_M31|`HV?E6)u$E3c&*<*n20+V@mRCop>R5;DWuZCmjSo7p@R&OYl^@Gv)e5ZBQx4|Y-Q?nr@Px3?9h(3ZWr3^tj=`TP57gKr87N$ zp2wWee1GRRCwo_xahnw)5cxNPJbCg2L6DV|6`#+yw6v6!mDS$f9-JvFD^n;GQ&UrZ zzh5jCkByB101O60U0q#p_1BM>Cv-vP?&s4@g_((4_1L=L$(a91)0=J91Gas#R{McE znYG^9*0A5YZ>#;~+Wkn(W5B0^yELIYLP!K}mB~<)AM@1&nqekynuaEGqPrzoH|KodRXJy)%+w_fu3nE5>@Bd_b zqC$EQ;{c`T&?EsNO|igL9gC7Ygxv?aQUEXMq?~>wg{EyW;VcJ37CUF#HjrT=KQO_* zS>M9yydXk18D(+QDJ1>r);Lav_uYKp$T?4vr{Q$lTo&pKv^?(>L-)G2*lwH!Ah7k? z7oH<8h-(KTKt5V6$8gF)C7Io&P5=SjTh)=zV=E2EUhQZP##L8S{d%UK>>+y82>+FV+#^BzW7u3F)Bb>=lYQ%%j`F>ASe zo*cw@V#u6T`A2He;70mR(V&iV&-7{qP~=SRf&jm9-T{*ZeZ}$rd0#6c&fLG^xJcf5 z+p<`wJYgW+_s*V{uI$nMB;%8`S_3>PfGOj3Rq}@Cx^+j?rk92fANSFDBYnOqQ>Vdj z)(|$AhP4t&Lb=Gvo2#3Gl%9<=Gv`Mz?Po@P4iLF!x}GUWJICDlFk-hS^Whyh7x~VH z@0vD1>HYD4&e+~yzS*-sFR{9`{QEEZO1zg7>R&7cHts-6j!xHVdA8eI+ZlVzd%`es zJT@$#GX(gvCJ1oJN%yLBK}{V=V;seo;!w|Yte!W1%5qLNFWqvZW>h&IiH+oPT=b@E zPhGzv5=(Un*X>v`>%8h_nj^NdYcE6NHS_ifkCV$*D)Tqrbu`s;<=t<4 zAHNqNV?6(g<1PY-w@#I-WYFViz?9TrkMr)u0g`O`u|>T;k|2sV*YF^punvT;$SuTy{j3Gv)yqD!R_CF>yR)MzmmYS5v+~R zXAdD%ng9?df;wd8GxR#%3O+gz};Vo;)sK%Bj-q>Oq%R7JU-KD?vYu>#2UjaDo z&8$>5xW~?KPD_#XFToU1hIb*VOMidUr6iYiO0N|i-7s`T8!cFT`rN!^1Pt78J93i6 z5HI1wIM$94m{3SLDvISDe6$ZG1;eq_D9RTaaC>=cO{@Bs>$IlPCPJJ$h$)-3vzNUQ6OsN#_zWxey!_9%hxwH2_dEJi=yY|1c7nDm2_Lm!Cof8-R_+9UkS zcBE(o47yE)oMR(Q=dp1a2wTX5KvvGyLqlWTa7V&!A*|w|)ax~1_~aJ0=_Lilg*0iQk7#ZD EAHN$8j{pDw literal 0 HcmV?d00001 diff --git a/addons/base_import/static/src/css/import.css b/addons/base_import/static/src/css/import.css index c613357537b..6f4770ce34c 100644 --- a/addons/base_import/static/src/css/import.css +++ b/addons/base_import/static/src/css/import.css @@ -44,15 +44,7 @@ text-align: right; } -.ui-menu .oe_import_completion_category { - background-color: #f0f0f0; - /* jquery-ui requirements otherwise background color is buggy */ - float: left; - zoom: 1; - width: 100%; - padding: 0; - margin: 0; -} -.ui-menu .ui-menu-item .oe_import_completion_required { - color: #2962ff; +.oe_import_selector ul, +.oe_import_selector li { + margin: 0; padding: 0; } diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index 2ec0caf1fc0..947955d94af 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -75,7 +75,6 @@ openerp.base_import = function (instance) { // import object id this.id = null; this.Import = new instance.web.Model('base_import.import'); - this.field_label_to_name = {}; }, start: function () { var self = this; @@ -130,38 +129,49 @@ openerp.base_import = function (instance) { var $fields = this.$('.oe_import_fields input'); this.render_fields_matches(result, $fields); - $fields.autocomplete({ - minLength: 0, - html: true, - source: this.generate_fields_completion(result) - }).data('autocomplete')._renderItem = function (ul, item) { - var $item = $('
    • ').data('item.autocomplete', item).appendTo(ul); - - if (item.category) { - return $item - .addClass('oe_import_completion_category') - .text(item.label); - } else { - return $item.append( - $('') - .toggleClass('oe_import_completion_required', !!item.required) - .text(item.label)); + var data = this.generate_fields_completion(result); + var item_finder = function (id, items) { + items = items || data; + for (var i=0; i < items.length; ++i) { + var item = items[i]; + if (item.id === id) { + return item; + } + var val; + if (item.children && (val = item_finder(id, item.children))) { + return val; + } } + return ''; }; + $fields.select2({ + allowClear: true, + minimumInputLength: 0, + data: data, + initSelection: function (element, callback) { + var default_value = element.val(); + if (!default_value) { + callback(''); + return; + } + + callback(item_finder(default_value)); + }, + + width: 'resolve', + dropdownCssClass: 'oe_import_selector' + }); this.import_dryrun(); }, generate_fields_completion: function (root) { - // Display nice names to user, but send logical names to server - this.field_label_to_name = {}; var basic = []; - var regulars = [{label: _t("Normal Fields"), category: true}]; - var o2m = [{label: _t("Relation Fields (o2m)"), category: true}]; - var self = this; + var regulars = []; + var o2m = []; function traverse(field, ancestors, collection) { var subfields = field.fields; var field_path = ancestors.concat(field); var label = _(field_path).pluck('string').join(' / '); - self.field_label_to_name[label] = _(field_path).pluck('name').join('/'); + var id = _(field_path).pluck('name').join('/'); // If non-relational, m2o or m2m, collection is regulars if (!collection) { @@ -176,7 +186,8 @@ openerp.base_import = function (instance) { } collection.push({ - label: label, + id: id, + text: label, required: field.required }); @@ -188,7 +199,10 @@ openerp.base_import = function (instance) { traverse(field, []); }); - return basic.concat(regulars, o2m); + return basic.concat([ + { text: _t("Normal Fields"), children: regulars }, + { text: _t("Relation Fields"), children: o2m } + ]); }, render_fields_matches: function (result, $fields) { if (_(result.matches).isEmpty()) { return; } @@ -205,18 +219,17 @@ openerp.base_import = function (instance) { return subfield.name === name; }); }) - .pluck('string') + .pluck('name') .value() - .join(' / '); + .join('/'); }); }, //- import itself call_import: function (options) { var self = this; - var fields = this.$('.oe_import_fields input').map(function (index, el) { - // Convert user-readable field label to logical db-backed name - return self.field_label_to_name[el.value] || false; + var fields = this.$('.oe_import_fields input.oe_import_match_field').map(function (index, el) { + return $(el).select2('val') || false; }).get(); return this.Import.call( 'do', [this.id, fields, this.import_options()], options); diff --git a/addons/base_import/static/src/xml/import.xml b/addons/base_import/static/src/xml/import.xml index a2844f8d7b9..733a93fd104 100644 --- a/addons/base_import/static/src/xml/import.xml +++ b/addons/base_import/static/src/xml/import.xml @@ -71,7 +71,8 @@
    • From 34d15bb862b67770f293c27d6ed1984f33ca69a8 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 10 Sep 2012 09:20:32 +0200 Subject: [PATCH 23/25] [IMP] base_import: lexicographically sort fields in field-matching UI bzr revid: xmo@openerp.com-20120910072032-wbgafg3z45z4l29a --- addons/base_import/static/src/js/import.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index 947955d94af..63a266ef6c0 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -199,6 +199,12 @@ openerp.base_import = function (instance) { traverse(field, []); }); + var cmp = function (field1, field2) { + return field1.text.localeCompare(field2.text); + + }; + regulars.sort(cmp); + o2m.sort(cmp); return basic.concat([ { text: _t("Normal Fields"), children: regulars }, { text: _t("Relation Fields"), children: o2m } From e401ca9d69a5a4f76bad7b21a6c9882f97cdfbef Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 11 Sep 2012 08:38:49 +0200 Subject: [PATCH 24/25] [IMP] re-introduce Import button/link when base_import is installed bzr revid: xmo@openerp.com-20120911063849-xqw380wglzxviojf --- addons/base_import/static/src/css/import.css | 7 +++++++ addons/base_import/static/src/js/import.js | 19 +++++++++++++++++++ addons/base_import/static/src/xml/import.xml | 8 ++++++++ 3 files changed, 34 insertions(+) diff --git a/addons/base_import/static/src/css/import.css b/addons/base_import/static/src/css/import.css index 6f4770ce34c..f6258f5aa50 100644 --- a/addons/base_import/static/src/css/import.css +++ b/addons/base_import/static/src/css/import.css @@ -1,3 +1,10 @@ +.openerp .oe_list_buttons .oe_alternative { + visibility: visible; +} +.openerp .oe_list_buttons.oe_editing .oe_list_button_import { + display: none; +} + .oe_import dd, .oe_import .oe_import_toggled, .oe_import .oe_import_grid, diff --git a/addons/base_import/static/src/js/import.js b/addons/base_import/static/src/js/import.js index 63a266ef6c0..c2deb660ff2 100644 --- a/addons/base_import/static/src/js/import.js +++ b/addons/base_import/static/src/js/import.js @@ -35,6 +35,25 @@ openerp.base_import = function (instance) { $(form).ajaxSubmit(attributes); } + // if true, the 'Import', 'Export', etc... buttons will be shown + instance.web.ListView.prototype.defaults.import_enabled = true; + instance.web.ListView.include({ + on_loaded: function () { + var self = this; + var add_button = false; + if (!this.$buttons) { + add_button = true; + } + this._super.apply(this, arguments); + if(add_button) { + this.$buttons.on('click', '.oe_list_button_import', function() { + new instance.web.DataImport(self, self.dataset).open(); + return false; + }); + } + } + }); + instance.web.DataImport = instance.web.Dialog.extend({ template: 'ImportView', dialog_title: _lt("Import Data"), diff --git a/addons/base_import/static/src/xml/import.xml b/addons/base_import/static/src/xml/import.xml index 733a93fd104..386348bea4b 100644 --- a/addons/base_import/static/src/xml/import.xml +++ b/addons/base_import/static/src/xml/import.xml @@ -92,4 +92,12 @@ + + + this.attr('t-if', 'widget.options.import_enabled'); + + + Import + + From 7bde4ab749e81b0010a552fa5d005a6d40c8757d Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 11 Sep 2012 09:08:58 +0200 Subject: [PATCH 25/25] [IMP] add base_import to configurator bzr revid: xmo@openerp.com-20120911070858-3eb3ny4ts05togk2 --- addons/base_setup/res_config.py | 1 + addons/base_setup/res_config_view.xml | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/addons/base_setup/res_config.py b/addons/base_setup/res_config.py index 4826a50400d..e1920fc05d9 100644 --- a/addons/base_setup/res_config.py +++ b/addons/base_setup/res_config.py @@ -35,6 +35,7 @@ class base_config_settings(osv.osv_memory): 'module_auth_anonymous': fields.boolean('activate the public portal', help="""Enable the public part of openerp, openerp becomes a public website."""), 'module_auth_oauth': fields.boolean('use external authentication providers, sign in with google, facebook, ...'), + 'module_base_import': fields.boolean("Allow users to import data from CSV files"), } def open_company(self, cr, uid, ids, context=None): diff --git a/addons/base_setup/res_config_view.xml b/addons/base_setup/res_config_view.xml index 512c0a3f52e..3852ca03a05 100644 --- a/addons/base_setup/res_config_view.xml +++ b/addons/base_setup/res_config_view.xml @@ -59,6 +59,15 @@ + +
      - +