[MERGE] base_import: phase 1 of new import
bzr revid: xmo@openerp.com-20120911100559-c4jjfb2dqylajdzk
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import controllers
|
||||
import models
|
||||
import tests.models
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
'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?
|
||||
'css': [
|
||||
'static/lib/select2/select2.css',
|
||||
'static/src/css/import.css',
|
||||
],
|
||||
'js': [
|
||||
'static/lib/select2/select2.js',
|
||||
'static/src/js/import.js',
|
||||
],
|
||||
'qweb': ['static/src/xml/import.xml'],
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
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': 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}))
|
||||
@@ -0,0 +1,352 @@
|
||||
import csv
|
||||
import itertools
|
||||
import logging
|
||||
import operator
|
||||
|
||||
try:
|
||||
from cStringIO import StringIO
|
||||
except ImportError:
|
||||
from StringIO import StringIO
|
||||
|
||||
import psycopg2
|
||||
|
||||
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'
|
||||
# allow imports to survive for 12h in case user is slow
|
||||
_transient_max_hours = 12.0
|
||||
|
||||
_columns = {
|
||||
'res_model': fields.char('Model', size=64),
|
||||
'file': fields.binary(
|
||||
'File', help="File to check and/or import, raw binary (not base64)"),
|
||||
'file_name': fields.char('File Name', size=None),
|
||||
'file_type': fields.char('File Type', 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)
|
||||
|
||||
# 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(record.file),
|
||||
quotechar=options['quoting'],
|
||||
delimiter=options['separator'])
|
||||
csv_nonempty = itertools.ifilter(None, csv_iterator)
|
||||
# TODO: guess encoding with chardet? Or https://github.com/aadsm/jschardet
|
||||
encoding = options.get('encoding', 'utf-8')
|
||||
return itertools.imap(
|
||||
lambda row: [item.decode(encoding) for item in row],
|
||||
csv_nonempty)
|
||||
|
||||
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 a pair of (None, None) if headers were not requested
|
||||
or the list of headers and a dict mapping cell indices
|
||||
to key paths in the ``fields`` tree
|
||||
|
||||
:param Iterator rows:
|
||||
:param dict fields:
|
||||
:param dict options:
|
||||
:rtype: (None, None) | (list(str), dict(int: list(str)))
|
||||
"""
|
||||
if not options.get('headers'):
|
||||
return None, None
|
||||
|
||||
headers = next(rows)
|
||||
return headers, 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
|
||||
columns.
|
||||
|
||||
If the headers are not requested (not options.headers),
|
||||
``matches`` and ``headers`` are both ``False``.
|
||||
|
||||
:param id: identifier of the import
|
||||
:param int count: number of preview lines to generate
|
||||
:param options: format-specific options.
|
||||
CSV: {encoding, quoting, separator, headers}
|
||||
:type options: {str, str, str, bool}
|
||||
:returns: {fields, matches, headers, preview} | {error, preview}
|
||||
:rtype: {dict(str: dict(...)), dict(int, list(str)), list(str), list(list(str))} | {str, str}
|
||||
"""
|
||||
(record,) = self.browse(cr, uid, [id], context=context)
|
||||
fields = self.get_fields(cr, uid, record.res_model, context=context)
|
||||
|
||||
try:
|
||||
rows = self._read_csv(record, options)
|
||||
|
||||
headers, matches = self._match_headers(rows, fields, options)
|
||||
# Match should have consumed the first row (iif headers), get
|
||||
# the ``count`` next rows for preview
|
||||
preview = itertools.islice(rows, count)
|
||||
return {
|
||||
'fields': fields,
|
||||
'matches': matches or False,
|
||||
'headers': headers or False,
|
||||
'preview': list(preview),
|
||||
}
|
||||
except Exception, e:
|
||||
# Due to lazy generators, UnicodeDecodeError (for
|
||||
# instance) may only be raised when serializing the
|
||||
# preview to a list in the return.
|
||||
_logger.debug("Error during CSV parsing preview", exc_info=True)
|
||||
return {
|
||||
'error': str(e),
|
||||
# iso-8859-1 ensures decoding will always succeed,
|
||||
# even if it yields non-printable characters. This is
|
||||
# in case of UnicodeDecodeError (or csv.Error
|
||||
# compounded with UnicodeDecodeError)
|
||||
'preview': record.file[:ERROR_PREVIEW_BYTES]
|
||||
.decode( 'iso-8859-1'),
|
||||
}
|
||||
|
||||
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))
|
||||
:raises ValueError: in case the import data could not be converted
|
||||
"""
|
||||
# Get indices for non-empty fields
|
||||
indices = [index for index, field in enumerate(fields) if field]
|
||||
if not indices:
|
||||
raise ValueError(_("You must configure at least one field to import"))
|
||||
# If only one index, itemgetter will return an atom rather
|
||||
# than a 1-tuple
|
||||
if len(indices) == 1: mapper = lambda row: [row[indices[0]]]
|
||||
else: mapper = operator.itemgetter(*indices)
|
||||
# Get only list of actually imported fields
|
||||
import_fields = filter(None, fields)
|
||||
|
||||
rows_to_import = self._read_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)
|
||||
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))
|
||||
(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 (most) invalid
|
||||
# input.
|
||||
code, record, message = -1, None, str(e)
|
||||
|
||||
# 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``?
|
||||
return [{
|
||||
'type': 'error',
|
||||
'message': message,
|
||||
'record': record or False
|
||||
}]
|
||||
@@ -0,0 +1,12 @@
|
||||
Copyright 2012 Igor Vaynberg
|
||||
|
||||
Version: @@ver@@ Timestamp: @@timestamp@@
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this work except in
|
||||
compliance with the License. You may obtain a copy of the License in the LICENSE file, or at:
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the License is
|
||||
distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and limitations under the License.
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
Select2
|
||||
=================
|
||||
|
||||
Select2 is a jQuery based replacement for select boxes. It supports searching, remote data sets, and infinite scrolling of results. Look and feel of Select2 is based on the excellent [Chosen](http://harvesthq.github.com/chosen/) library.
|
||||
|
||||
To get started -- checkout http://ivaynberg.github.com/select2!
|
||||
|
||||
What Does Select2 Support That Chosen Does Not?
|
||||
-------------------------------------------------
|
||||
|
||||
* Working with large datasets: Chosen requires the entire dataset to be loaded as `option` tags in the DOM, which limits
|
||||
it to working with small-ish datasets. Select2 uses a function to find results on-the-fly, which allows it to partially
|
||||
load results.
|
||||
* Paging of results: Since Select2 works with large datasets and only loads a small amount of matching results at a time
|
||||
it has to support paging. Select2 will call the search function when the user scrolls to the bottom of currently loaded
|
||||
result set allowing for the 'infinite scrolling' of results.
|
||||
* Custom markup for results: Chosen only supports rendering text results because that is the only markup supported by
|
||||
`option` tags. Select2 provides an extension point which can be used to produce any kind of markup to represent results.
|
||||
* Ability to add results on the fly: Select2 provides the ability to add results from the search term entered by the user, which allows it to be used for
|
||||
tagging.
|
||||
|
||||
Browser Compatibility
|
||||
--------------------
|
||||
* IE 8+ (7 mostly works except for [issue with z-index](https://github.com/ivaynberg/select2/issues/37))
|
||||
* Chrome 8+
|
||||
* Firefox 3.5+
|
||||
* Safari 3+
|
||||
* Opera 10.6+
|
||||
|
||||
Integrations
|
||||
------------
|
||||
|
||||
* [Wicket-Select2](https://github.com/ivaynberg/wicket-select2) (Java / Apache Wicket)
|
||||
* [select2-rails](https://github.com/argerim/select2-rails) (Ruby on Rails)
|
||||
* [AngularUI](http://angular-ui.github.com/#directives-select2) ([AngularJS](angularjs.org))
|
||||
* [Django](https://github.com/applegrew/django-select2)
|
||||
|
||||
Bug tracker
|
||||
-----------
|
||||
|
||||
Have a bug? Please create an issue here on GitHub!
|
||||
|
||||
https://github.com/ivaynberg/select2/issues
|
||||
|
||||
|
||||
Mailing list
|
||||
------------
|
||||
|
||||
Have a question? Ask on our mailing list!
|
||||
|
||||
select2@googlegroups.com
|
||||
|
||||
https://groups.google.com/d/forum/select2
|
||||
|
||||
|
||||
Copyright and License
|
||||
---------------------
|
||||
|
||||
Copyright 2012 Igor Vaynberg
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this work except in
|
||||
compliance with the License. You may obtain a copy of the License in the LICENSE file, or at:
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the License is
|
||||
distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and limitations under the License.
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo -n "Enter the version for this release: "
|
||||
|
||||
read ver
|
||||
|
||||
if [ ! $ver ]; then
|
||||
echo "Invalid version."
|
||||
exit
|
||||
fi
|
||||
|
||||
name="select2"
|
||||
js="$name.js"
|
||||
mini="$name.min.js"
|
||||
css="$name.css"
|
||||
release="$name-$ver"
|
||||
releasedir="/tmp/$release"
|
||||
tag="release-$ver"
|
||||
branch="build-$ver"
|
||||
curbranch=`git branch | grep "*" | sed "s/* //"`
|
||||
timestamp=$(date)
|
||||
tokens="s/@@ver@@/$ver/g;s/\@@timestamp@@/$timestamp/g"
|
||||
remote="github"
|
||||
|
||||
git branch "$branch"
|
||||
git checkout "$branch"
|
||||
|
||||
echo "Tokenizing..."
|
||||
|
||||
find . -name "$js" | xargs sed -i -e "$tokens"
|
||||
find . -name "$css" | xargs sed -i -e "$tokens"
|
||||
|
||||
git add "$js"
|
||||
git add "$css"
|
||||
|
||||
echo "Minifying..."
|
||||
|
||||
echo "/*" > "$mini"
|
||||
cat LICENSE | sed "$tokens" >> "$mini"
|
||||
echo "*/" >> "$mini"
|
||||
|
||||
curl -s \
|
||||
-d compilation_level=SIMPLE_OPTIMIZATIONS \
|
||||
-d output_format=text \
|
||||
-d output_info=compiled_code \
|
||||
--data-urlencode "js_code@$js" \
|
||||
http://closure-compiler.appspot.com/compile \
|
||||
>> "$mini"
|
||||
|
||||
git add "$mini"
|
||||
|
||||
git commit -m "release $ver"
|
||||
|
||||
echo "Tagging..."
|
||||
|
||||
git tag -a "$tag" -m "tagged version $ver"
|
||||
git push "$remote" --tags
|
||||
|
||||
echo "Archiving..."
|
||||
|
||||
rm -rf "$releasedir"
|
||||
mkdir "$releasedir"
|
||||
|
||||
cp $name.* "$releasedir"
|
||||
cp spinner.gif "$releasedir"
|
||||
cp README.* "$releasedir"
|
||||
|
||||
zip -r "$releasedir.zip" "$releasedir"
|
||||
rm -rf "$releasedir"
|
||||
|
||||
echo "Cleaning Up..."
|
||||
|
||||
git checkout "$curbranch"
|
||||
git branch -D "$branch"
|
||||
|
||||
echo "Done. Release archive created: $releasedir.zip"
|
||||
+524
@@ -0,0 +1,524 @@
|
||||
/*
|
||||
Version: @@ver@@ Timestamp: @@timestamp@@
|
||||
*/
|
||||
.select2-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
/* inline-block for ie7 */
|
||||
zoom: 1;
|
||||
*display: inline;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.select2-container,
|
||||
.select2-drop,
|
||||
.select2-search,
|
||||
.select2-search input{
|
||||
/*
|
||||
Force border-box so that % widths fit the parent
|
||||
container without overlap because of margin/padding.
|
||||
|
||||
More Info : http://www.quirksmode.org/css/box.html
|
||||
*/
|
||||
-moz-box-sizing: border-box; /* firefox */
|
||||
-ms-box-sizing: border-box; /* ie */
|
||||
-webkit-box-sizing: border-box; /* webkit */
|
||||
-khtml-box-sizing: border-box; /* konqueror */
|
||||
box-sizing: border-box; /* css3 */
|
||||
}
|
||||
|
||||
.select2-container .select2-choice {
|
||||
background-color: #fff;
|
||||
background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eeeeee), color-stop(0.5, white));
|
||||
background-image: -webkit-linear-gradient(center bottom, #eeeeee 0%, white 50%);
|
||||
background-image: -moz-linear-gradient(center bottom, #eeeeee 0%, white 50%);
|
||||
background-image: -o-linear-gradient(bottom, #eeeeee 0%, #ffffff 50%);
|
||||
background-image: -ms-linear-gradient(top, #eeeeee 0%, #ffffff 50%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#eeeeee', endColorstr = '#ffffff', GradientType = 0);
|
||||
background-image: linear-gradient(top, #eeeeee 0%, #ffffff 50%);
|
||||
-webkit-border-radius: 4px;
|
||||
-moz-border-radius: 4px;
|
||||
border-radius: 4px;
|
||||
-moz-background-clip: padding;
|
||||
-webkit-background-clip: padding-box;
|
||||
background-clip: padding-box;
|
||||
border: 1px solid #aaa;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
padding: 0 0 0 8px;
|
||||
color: #444;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.select2-container.select2-drop-above .select2-choice
|
||||
{
|
||||
border-bottom-color: #aaa;
|
||||
-webkit-border-radius:0px 0px 4px 4px;
|
||||
-moz-border-radius:0px 0px 4px 4px;
|
||||
border-radius:0px 0px 4px 4px;
|
||||
background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eeeeee), color-stop(0.9, white));
|
||||
background-image: -webkit-linear-gradient(center bottom, #eeeeee 0%, white 90%);
|
||||
background-image: -moz-linear-gradient(center bottom, #eeeeee 0%, white 90%);
|
||||
background-image: -o-linear-gradient(bottom, #eeeeee 0%, white 90%);
|
||||
background-image: -ms-linear-gradient(top, #eeeeee 0%,#ffffff 90%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#eeeeee', endColorstr='#ffffff',GradientType=0 );
|
||||
background-image: linear-gradient(top, #eeeeee 0%,#ffffff 90%);
|
||||
}
|
||||
|
||||
.select2-container .select2-choice span {
|
||||
margin-right: 26px;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
-o-text-overflow: ellipsis;
|
||||
-ms-text-overflow: ellipsis;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.select2-container .select2-choice abbr {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 26px;
|
||||
top: 8px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 1px;
|
||||
background: url('select2.png') right top no-repeat;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border:0;
|
||||
outline: 0;
|
||||
}
|
||||
.select2-container .select2-choice abbr:hover {
|
||||
background-position: right -11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select2-drop {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border: 1px solid #aaa;
|
||||
border-top: 0;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
-webkit-box-shadow: 0 4px 5px rgba(0, 0, 0, .15);
|
||||
-moz-box-shadow: 0 4px 5px rgba(0, 0, 0, .15);
|
||||
-o-box-shadow: 0 4px 5px rgba(0, 0, 0, .15);
|
||||
box-shadow: 0 4px 5px rgba(0, 0, 0, .15);
|
||||
z-index: 9999;
|
||||
width:100%;
|
||||
margin-top:-1px;
|
||||
|
||||
-webkit-border-radius: 0 0 4px 4px;
|
||||
-moz-border-radius: 0 0 4px 4px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.select2-drop.select2-drop-above {
|
||||
-webkit-border-radius: 4px 4px 0px 0px;
|
||||
-moz-border-radius: 4px 4px 0px 0px;
|
||||
border-radius: 4px 4px 0px 0px;
|
||||
margin-top:1px;
|
||||
border-top: 1px solid #aaa;
|
||||
border-bottom: 0;
|
||||
|
||||
-webkit-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15);
|
||||
-moz-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15);
|
||||
-o-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15);
|
||||
box-shadow: 0 -4px 5px rgba(0, 0, 0, .15);
|
||||
}
|
||||
|
||||
.select2-container .select2-choice div {
|
||||
-webkit-border-radius: 0 4px 4px 0;
|
||||
-moz-border-radius: 0 4px 4px 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
-moz-background-clip: padding;
|
||||
-webkit-background-clip: padding-box;
|
||||
background-clip: padding-box;
|
||||
background: #ccc;
|
||||
background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #ccc), color-stop(0.6, #eee));
|
||||
background-image: -webkit-linear-gradient(center bottom, #ccc 0%, #eee 60%);
|
||||
background-image: -moz-linear-gradient(center bottom, #ccc 0%, #eee 60%);
|
||||
background-image: -o-linear-gradient(bottom, #ccc 0%, #eee 60%);
|
||||
background-image: -ms-linear-gradient(top, #cccccc 0%, #eeeeee 60%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#cccccc', endColorstr = '#eeeeee', GradientType = 0);
|
||||
background-image: linear-gradient(top, #cccccc 0%, #eeeeee 60%);
|
||||
border-left: 1px solid #aaa;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.select2-container .select2-choice div b {
|
||||
background: url('select2.png') no-repeat 0 1px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.select2-search {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
z-index: 10000;
|
||||
min-height: 26px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.select2-search-hidden {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: -10000px;
|
||||
}
|
||||
|
||||
.select2-search input {
|
||||
background: #fff url('select2.png') no-repeat 100% -22px;
|
||||
background: url('select2.png') no-repeat 100% -22px, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee));
|
||||
background: url('select2.png') no-repeat 100% -22px, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%);
|
||||
background: url('select2.png') no-repeat 100% -22px, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%);
|
||||
background: url('select2.png') no-repeat 100% -22px, -o-linear-gradient(bottom, white 85%, #eeeeee 99%);
|
||||
background: url('select2.png') no-repeat 100% -22px, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%);
|
||||
background: url('select2.png') no-repeat 100% -22px, linear-gradient(top, #ffffff 85%, #eeeeee 99%);
|
||||
padding: 4px 20px 4px 5px;
|
||||
outline: 0;
|
||||
border: 1px solid #aaa;
|
||||
font-family: sans-serif;
|
||||
font-size: 1em;
|
||||
width:100%;
|
||||
margin:0;
|
||||
height:auto !important;
|
||||
min-height: 26px;
|
||||
-webkit-box-shadow: none;
|
||||
-moz-box-shadow: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
-moz-border-radius: 0;
|
||||
-webkit-border-radius: 0;
|
||||
}
|
||||
|
||||
.select2-drop.select2-drop-above .select2-search input
|
||||
{
|
||||
margin-top:4px;
|
||||
}
|
||||
|
||||
.select2-search input.select2-active {
|
||||
background: #fff url('spinner.gif') no-repeat 100%;
|
||||
background: url('spinner.gif') no-repeat 100%, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee));
|
||||
background: url('spinner.gif') no-repeat 100%, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%);
|
||||
background: url('spinner.gif') no-repeat 100%, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%);
|
||||
background: url('spinner.gif') no-repeat 100%, -o-linear-gradient(bottom, white 85%, #eeeeee 99%);
|
||||
background: url('spinner.gif') no-repeat 100%, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%);
|
||||
background: url('spinner.gif') no-repeat 100%, linear-gradient(top, #ffffff 85%, #eeeeee 99%);
|
||||
}
|
||||
|
||||
|
||||
.select2-container-active .select2-choice,
|
||||
.select2-container-active .select2-choices {
|
||||
-webkit-box-shadow: 0 0 5px rgba(0,0,0,.3);
|
||||
-moz-box-shadow : 0 0 5px rgba(0,0,0,.3);
|
||||
-o-box-shadow : 0 0 5px rgba(0,0,0,.3);
|
||||
box-shadow : 0 0 5px rgba(0,0,0,.3);
|
||||
border: 1px solid #5897fb;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.select2-dropdown-open .select2-choice {
|
||||
border: 1px solid #aaa;
|
||||
border-bottom-color: transparent;
|
||||
-webkit-box-shadow: 0 1px 0 #fff inset;
|
||||
-moz-box-shadow : 0 1px 0 #fff inset;
|
||||
-o-box-shadow : 0 1px 0 #fff inset;
|
||||
box-shadow : 0 1px 0 #fff inset;
|
||||
background-color: #eee;
|
||||
background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, white), color-stop(0.5, #eeeeee));
|
||||
background-image: -webkit-linear-gradient(center bottom, white 0%, #eeeeee 50%);
|
||||
background-image: -moz-linear-gradient(center bottom, white 0%, #eeeeee 50%);
|
||||
background-image: -o-linear-gradient(bottom, white 0%, #eeeeee 50%);
|
||||
background-image: -ms-linear-gradient(top, #ffffff 0%,#eeeeee 50%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffff', endColorstr='#eeeeee',GradientType=0 );
|
||||
background-image: linear-gradient(top, #ffffff 0%,#eeeeee 50%);
|
||||
-webkit-border-bottom-left-radius : 0;
|
||||
-webkit-border-bottom-right-radius: 0;
|
||||
-moz-border-radius-bottomleft : 0;
|
||||
-moz-border-radius-bottomright: 0;
|
||||
border-bottom-left-radius : 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.select2-dropdown-open .select2-choice div {
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
}
|
||||
.select2-dropdown-open .select2-choice div b {
|
||||
background-position: -18px 1px;
|
||||
}
|
||||
|
||||
/* results */
|
||||
.select2-results {
|
||||
margin: 4px 4px 4px 0;
|
||||
padding: 0 0 0 4px;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.select2-results ul.select2-result-sub {
|
||||
margin: 0 0 0 0;
|
||||
}
|
||||
|
||||
.select2-results ul.select2-result-sub > li .select2-result-label { padding-left: 20px }
|
||||
.select2-results ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 40px }
|
||||
.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 60px }
|
||||
.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 80px }
|
||||
.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 100px }
|
||||
.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 110px }
|
||||
.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 120px }
|
||||
|
||||
.select2-results li {
|
||||
list-style: none;
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
.select2-results li.select2-result-with-children > .select2-result-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.select2-results .select2-result-label {
|
||||
padding: 3px 7px 4px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select2-results .select2-highlighted {
|
||||
background: #3875d7;
|
||||
color: #fff;
|
||||
}
|
||||
.select2-results li em {
|
||||
background: #feffde;
|
||||
font-style: normal;
|
||||
}
|
||||
.select2-results .select2-highlighted em {
|
||||
background: transparent;
|
||||
}
|
||||
.select2-results .select2-no-results,
|
||||
.select2-results .select2-searching,
|
||||
.select2-results .select2-selection-limit {
|
||||
background: #f4f4f4;
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/*
|
||||
disabled look for already selected choices in the results dropdown
|
||||
.select2-results .select2-disabled.select2-highlighted {
|
||||
color: #666;
|
||||
background: #f4f4f4;
|
||||
display: list-item;
|
||||
cursor: default;
|
||||
}
|
||||
.select2-results .select2-disabled {
|
||||
background: #f4f4f4;
|
||||
display: list-item;
|
||||
cursor: default;
|
||||
}
|
||||
*/
|
||||
.select2-results .select2-disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.select2-more-results.select2-active {
|
||||
background: #f4f4f4 url('spinner.gif') no-repeat 100%;
|
||||
}
|
||||
|
||||
.select2-more-results {
|
||||
background: #f4f4f4;
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/* disabled styles */
|
||||
|
||||
.select2-container.select2-container-disabled .select2-choice {
|
||||
background-color: #f4f4f4;
|
||||
background-image: none;
|
||||
border: 1px solid #ddd;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.select2-container.select2-container-disabled .select2-choice div {
|
||||
background-color: #f4f4f4;
|
||||
background-image: none;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
|
||||
/* multiselect */
|
||||
|
||||
.select2-container-multi .select2-choices {
|
||||
background-color: #fff;
|
||||
background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(1%, #eeeeee), color-stop(15%, #ffffff));
|
||||
background-image: -webkit-linear-gradient(top, #eeeeee 1%, #ffffff 15%);
|
||||
background-image: -moz-linear-gradient(top, #eeeeee 1%, #ffffff 15%);
|
||||
background-image: -o-linear-gradient(top, #eeeeee 1%, #ffffff 15%);
|
||||
background-image: -ms-linear-gradient(top, #eeeeee 1%, #ffffff 15%);
|
||||
background-image: linear-gradient(top, #eeeeee 1%, #ffffff 15%);
|
||||
border: 1px solid #aaa;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: text;
|
||||
overflow: hidden;
|
||||
height: auto !important;
|
||||
height: 1%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.select2-container-multi .select2-choices {
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.select2-container-multi.select2-container-active .select2-choices {
|
||||
-webkit-box-shadow: 0 0 5px rgba(0,0,0,.3);
|
||||
-moz-box-shadow : 0 0 5px rgba(0,0,0,.3);
|
||||
-o-box-shadow : 0 0 5px rgba(0,0,0,.3);
|
||||
box-shadow : 0 0 5px rgba(0,0,0,.3);
|
||||
border: 1px solid #5897fb;
|
||||
outline: none;
|
||||
}
|
||||
.select2-container-multi .select2-choices li {
|
||||
float: left;
|
||||
list-style: none;
|
||||
}
|
||||
.select2-container-multi .select2-choices .select2-search-field {
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.select2-container-multi .select2-choices .select2-search-field input {
|
||||
color: #666;
|
||||
background: transparent !important;
|
||||
font-family: sans-serif;
|
||||
font-size: 100%;
|
||||
height: 15px;
|
||||
padding: 5px;
|
||||
margin: 1px 0;
|
||||
outline: 0;
|
||||
border: 0;
|
||||
-webkit-box-shadow: none;
|
||||
-moz-box-shadow : none;
|
||||
-o-box-shadow : none;
|
||||
box-shadow : none;
|
||||
}
|
||||
|
||||
.select2-container-multi .select2-choices .select2-search-field input.select2-active {
|
||||
background: #fff url('spinner.gif') no-repeat 100% !important;
|
||||
}
|
||||
|
||||
.select2-default {
|
||||
color: #999 !important;
|
||||
}
|
||||
|
||||
.select2-container-multi .select2-choices .select2-search-choice {
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius : 3px;
|
||||
border-radius : 3px;
|
||||
-moz-background-clip : padding;
|
||||
-webkit-background-clip: padding-box;
|
||||
background-clip : padding-box;
|
||||
background-color: #e4e4e4;
|
||||
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#f4f4f4', endColorstr='#eeeeee', GradientType=0 );
|
||||
background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(20%, #f4f4f4), color-stop(50%, #f0f0f0), color-stop(52%, #e8e8e8), color-stop(100%, #eeeeee));
|
||||
background-image: -webkit-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%);
|
||||
background-image: -moz-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%);
|
||||
background-image: -o-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%);
|
||||
background-image: -ms-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%);
|
||||
background-image: linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%);
|
||||
-webkit-box-shadow: 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05);
|
||||
-moz-box-shadow : 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05);
|
||||
box-shadow : 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05);
|
||||
color: #333;
|
||||
border: 1px solid #aaaaaa;
|
||||
line-height: 13px;
|
||||
padding: 3px 5px 3px 18px;
|
||||
margin: 3px 0 3px 5px;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
}
|
||||
.select2-container-multi .select2-choices .select2-search-choice span {
|
||||
cursor: default;
|
||||
}
|
||||
.select2-container-multi .select2-choices .select2-search-choice-focus {
|
||||
background: #d4d4d4;
|
||||
}
|
||||
|
||||
.select2-search-choice-close {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
top: 4px;
|
||||
width: 12px;
|
||||
height: 13px;
|
||||
font-size: 1px;
|
||||
background: url('select2.png') right top no-repeat;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.select2-container-multi .select2-search-choice-close {
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
|
||||
.select2-container-multi .select2-choices .select2-search-choice .select2-search-choice-close:hover {
|
||||
background-position: right -11px;
|
||||
}
|
||||
.select2-container-multi .select2-choices .select2-search-choice-focus .select2-search-choice-close {
|
||||
background-position: right -11px;
|
||||
}
|
||||
|
||||
/* disabled styles */
|
||||
|
||||
.select2-container-multi.select2-container-disabled .select2-choices{
|
||||
background-color: #f4f4f4;
|
||||
background-image: none;
|
||||
border: 1px solid #ddd;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice {
|
||||
background-image: none;
|
||||
background-color: #f4f4f4;
|
||||
border: 1px solid #ddd;
|
||||
padding: 3px 5px 3px 5px;
|
||||
}
|
||||
|
||||
.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close {
|
||||
display: none;
|
||||
}
|
||||
/* end multiselect */
|
||||
|
||||
.select2-result-selectable .select2-match,
|
||||
.select2-result-unselectable .select2-result-selectable .select2-match { text-decoration: underline; }
|
||||
.select2-result-unselectable .select2-match { text-decoration: none; }
|
||||
|
||||
.select2-offscreen { position: absolute; left: -10000px; }
|
||||
|
||||
/* Retina-ize icons */
|
||||
|
||||
@media only screen and (-webkit-min-device-pixel-ratio: 1.5) {
|
||||
.select2-search input, .select2-search-choice-close, .select2-container .select2-choice abbr, .select2-container .select2-choice div b {
|
||||
background-image: url(select2x2.png) !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-size: 60px 40px !important;
|
||||
}
|
||||
.select2-search input {
|
||||
background-position: 100% -21px !important;
|
||||
}
|
||||
}
|
||||
+2407
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 613 B |
Binary file not shown.
|
After Width: | Height: | Size: 845 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
+57
@@ -0,0 +1,57 @@
|
||||
.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,
|
||||
.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;
|
||||
}
|
||||
|
||||
.oe_import a.oe_import_toggle {
|
||||
display: block;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.oe_import_selector ul,
|
||||
.oe_import_selector li {
|
||||
margin: 0; padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
openerp.base_import = function (instance) {
|
||||
var QWeb = instance.web.qweb;
|
||||
var _t = instance.web._t;
|
||||
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);
|
||||
}
|
||||
|
||||
// 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"),
|
||||
opts: [
|
||||
{name: 'encoding', label: _lt("Encoding:"), value: 'utf-8'},
|
||||
{name: 'separator', label: _lt("Separator:"), value: ','},
|
||||
{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) {
|
||||
e.preventDefault();
|
||||
},
|
||||
'click a.oe_import_export': function (e) {
|
||||
e.preventDefault();
|
||||
},
|
||||
'click a.oe_import_toggle': function (e) {
|
||||
e.preventDefault();
|
||||
var $el = $(e.target);
|
||||
($el.next().length
|
||||
? $el.next()
|
||||
: $el.parent().next())
|
||||
.toggle();
|
||||
}
|
||||
},
|
||||
init: function (parent, dataset) {
|
||||
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;
|
||||
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);
|
||||
});
|
||||
},
|
||||
|
||||
import_options: function () {
|
||||
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
|
||||
file_update: function (e) {
|
||||
if (!this.$('input.oe_import_file').val()) { return; }
|
||||
|
||||
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.$el.addClass('oe_import_with_file');
|
||||
// TODO: test that write // succeeded?
|
||||
this.Import.call(
|
||||
'parse_preview', [this.id, this.import_options()])
|
||||
.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(
|
||||
QWeb.render('ImportView.preview.error', 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);
|
||||
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) {
|
||||
var basic = [];
|
||||
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(' / ');
|
||||
var id = _(field_path).pluck('name').join('/');
|
||||
|
||||
// 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({
|
||||
id: id,
|
||||
text: label,
|
||||
required: field.required
|
||||
});
|
||||
|
||||
for(var i=0, end=subfields.length; i<end; ++i) {
|
||||
traverse(subfields[i], field_path, collection);
|
||||
}
|
||||
}
|
||||
_(root.fields).each(function (field) {
|
||||
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 }
|
||||
]);
|
||||
},
|
||||
render_fields_matches: function (result, $fields) {
|
||||
if (_(result.matches).isEmpty()) { return; }
|
||||
$fields.each(function (index, input) {
|
||||
var match = result.matches[index];
|
||||
if (!match) { return; }
|
||||
|
||||
var current_field = result;
|
||||
input.value = _(match).chain()
|
||||
.map(function (name) {
|
||||
// WARNING: does both mapping and folding (over the
|
||||
// ``field`` iterator variable)
|
||||
return current_field = _(current_field.fields).find(function (subfield) {
|
||||
return subfield.name === name;
|
||||
});
|
||||
})
|
||||
.pluck('name')
|
||||
.value()
|
||||
.join('/');
|
||||
});
|
||||
},
|
||||
|
||||
//- import itself
|
||||
call_import: function (options) {
|
||||
var self = this;
|
||||
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);
|
||||
},
|
||||
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;
|
||||
}
|
||||
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
|
||||
// warnings?)
|
||||
this.$el.addClass('oe_import_error');
|
||||
this.$('.oe_import_error_report').html(
|
||||
QWeb.render('ImportView.error', {errors: errors}));
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
<templates>
|
||||
<t t-name="ImportView">
|
||||
<t t-set="_id" t-value="_.uniqueId('export')"/>
|
||||
<form action="" method="post" enctype="multipart/form-data" class="oe_import">
|
||||
<input type="hidden" name="session_id"
|
||||
t-att-value="widget.session.session_id"/>
|
||||
<input type="hidden" name="import_id"/>
|
||||
<h2>Upload your file</h2>
|
||||
<p>Select the <a href="#" class="oe_import_csv">.CSV</a>
|
||||
file to import. If you need a sample importable file, you
|
||||
can use <a href="#" class="oe_import_export">the export
|
||||
tool</a> to generate one.</p>
|
||||
<label t-attf-for="file_#{_id}" autofocus="autofocus">CSV File:</label>
|
||||
<input type="file" id-attf-id="file_#{_id}"
|
||||
name="file" class="oe_import_file"/>
|
||||
|
||||
<div class="oe_import_with_file">
|
||||
<h2>Map your data to OpenERP</h2>
|
||||
<input type="checkbox" class="oe_import_has_header"
|
||||
id="oe_import_has_header" checked="checked"/>
|
||||
<label for="oe_import_has_header">The first row of the
|
||||
file contains the label of the column</label>
|
||||
<p class="oe_import_noheaders">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.</p>
|
||||
|
||||
<div class="oe_import_error_report"></div>
|
||||
<table class="oe_import_grid" width="100%"/>
|
||||
<a href="#" class="oe_import_toggle">
|
||||
File Format Options…</a>
|
||||
<div class="oe_import_toggled oe_import_options">
|
||||
<p t-foreach="widget.opts" t-as="option">
|
||||
<!-- no @name, avoid submission when file_update called -->
|
||||
<label t-attf-for="#{option.name}_#{_id}">
|
||||
<t t-esc="option.label"/></label>
|
||||
<input t-attf-id="#{option.name}_#{_id}"
|
||||
t-attf-class="oe_import_#{option.name}"
|
||||
t-att-value="option.value"/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Frequently Asked Questions</h2>
|
||||
<dl>
|
||||
<dt><a href="#" class="oe_import_toggle">
|
||||
Need to import data from an other application?</a></dt>
|
||||
<dd>
|
||||
<p>In order to re-create relationships between
|
||||
different records, you should use the unique
|
||||
identifier from the original application and
|
||||
map it to the <abbr title="External ID">ID</abbr>
|
||||
column in OpenERP. When you
|
||||
import an other record that links to the first
|
||||
one, use <abbr title="XXX/External ID">XXX/ID</abbr>
|
||||
to the original unique identifier.</p>
|
||||
<p>The <abbr title="External ID">ID</abbr>
|
||||
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</p>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</form>
|
||||
</t>
|
||||
<t t-name="ImportView.preview">
|
||||
<tr t-if="headers" class="oe_import_grid-header">
|
||||
<td t-foreach="headers" t-as="header" class="oe_import_grid-cell"
|
||||
><t t-esc="header"/></td>
|
||||
</tr>
|
||||
<tr class="oe_import_fields">
|
||||
<!-- Iterate on first row to ensure we have all columns -->
|
||||
<td t-foreach="preview[0]" t-as="column">
|
||||
<input placeholder="Don't Import"
|
||||
class="oe_import_match_field"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-foreach="preview" t-as="row" class="oe_import_grid-row">
|
||||
<td t-foreach="row" t-as="cell" class="oe_import_grid-cell"
|
||||
><t t-esc="cell"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
<t t-name="ImportView.preview.error">
|
||||
<p>Import preview failed due to: <t t-esc="error"/></p>
|
||||
<p>Here is the start of the file we could not import:</p>
|
||||
<pre><t t-esc="preview"/></pre>
|
||||
</t>
|
||||
<ul t-name="ImportView.error">
|
||||
<li t-foreach="errors" t-as="error" t-attf-class="oe_import_report_#{error.type}">
|
||||
<!-- can also have error.record, but may be *huge* if
|
||||
e.g. has image fields -->
|
||||
<t t-esc="error.message"/>
|
||||
</li>
|
||||
</ul>
|
||||
<t t-extend="ListView.buttons">
|
||||
<t t-jquery="span.oe_alternative">
|
||||
this.attr('t-if', 'widget.options.import_enabled');
|
||||
</t>
|
||||
<t t-jquery="span.oe_alternative" t-operation="append">
|
||||
<a href="#" class="oe_bold oe_list_button_import">Import</a>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import test_cases
|
||||
|
||||
checks = [test_cases]
|
||||
@@ -0,0 +1,101 @@
|
||||
from openerp.osv import orm, fields
|
||||
|
||||
def name(n): return 'base_import.tests.models.%s' % n
|
||||
|
||||
class char(orm.Model):
|
||||
_name = name('char')
|
||||
|
||||
_columns = {
|
||||
'value': fields.char('unknown', 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()
|
||||
}
|
||||
|
||||
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'),
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
# -*- 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 [
|
||||
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': []},
|
||||
]))
|
||||
|
||||
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.assertEqual(
|
||||
self.registry('base_import.import')._match_headers(
|
||||
[], [], {}),
|
||||
(None, None)
|
||||
)
|
||||
def test_nomatch(self):
|
||||
self.assertEqual(
|
||||
self.registry('base_import.import')._match_headers(
|
||||
iter([
|
||||
['foo', 'bar', 'baz', 'qux'],
|
||||
['v1', 'v2', 'v3', 'v4'],
|
||||
]),
|
||||
[],
|
||||
{'headers': True}),
|
||||
(
|
||||
['foo', 'bar', 'baz', 'qux'],
|
||||
dict.fromkeys(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}),
|
||||
(['foo', 'bar', 'baz', 'qux/corge'], {
|
||||
0: None,
|
||||
1: ['bar'],
|
||||
2: ['bob'],
|
||||
3: ['qux', 'corge'],
|
||||
})
|
||||
)
|
||||
|
||||
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': u"로그인,언어\nbob,1\n".encode('euc_kr'),
|
||||
})
|
||||
return Import, id
|
||||
|
||||
def test_encoding(self):
|
||||
Import, id = self.make_import()
|
||||
result = Import.parse_preview(self.cr, self.uid, id, {
|
||||
'quoting': '"',
|
||||
'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, {
|
||||
'quoting': '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, {
|
||||
'quoting': '"',
|
||||
'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': '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, {
|
||||
'quoting': '"',
|
||||
'separator': ',',
|
||||
'headers': True,
|
||||
})
|
||||
|
||||
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'], [
|
||||
{'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', 'headers', 'fields', 'preview'])
|
||||
|
||||
class test_convert_import_data(TransactionCase):
|
||||
""" Tests conversion of base_import.import input into data which
|
||||
can be fed to Model.import_data
|
||||
"""
|
||||
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': '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'],
|
||||
{'quoting': '"', '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': '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'],
|
||||
{'quoting': '"', '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': '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'],
|
||||
{'quoting': '"', 'separator': ',', 'headers': True,})
|
||||
|
||||
self.assertItemsEqual(fields, ['name', 'othervalue'])
|
||||
self.assertItemsEqual(data, [
|
||||
('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': '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, [],
|
||||
{'quoting': '"', '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': '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],
|
||||
{'quoting': '"', 'separator': ',', 'headers': True,})
|
||||
@@ -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):
|
||||
|
||||
@@ -60,6 +60,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</group>
|
||||
<group>
|
||||
<label for="id" string="Import / Export"/>
|
||||
<div>
|
||||
<div>
|
||||
<field name="module_base_import" class="oe_inline"/>
|
||||
<label for="module_base_import"/>
|
||||
</div>
|
||||
</div>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
Reference in New Issue
Block a user