[IMP] import/export UIs

Task 40692

Various changes to import/export (mainly) UIs:

* default to excel & "full" (non-import-compatible) export
* auto-detect encoding of CSV using chardet
* remember column -> field mapping after having imported a file (useful
   for repeated imports where auto-matching failed)
* better handle localised booleans & column names
* automatically select source list view's fields when exporting
* better integrate import templates feature and add a number of templates
This commit is contained in:
Jigar Patel
2018-07-17 15:19:05 +02:00
committed by xmo-odoo
parent 57676dc7d1
commit b60de0db41
37 changed files with 440 additions and 266 deletions
+104 -29
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
import chardet
import datetime import datetime
import io import io
import itertools import itertools
@@ -45,6 +46,37 @@ EXTENSIONS = {
for mime, (ext, handler, req) in FILE_TYPE_DICT.items() for mime, (ext, handler, req) in FILE_TYPE_DICT.items()
} }
class Base(models.AbstractModel):
_inherit = 'base'
@api.model
def get_import_templates(self):
"""
Get the import templates label and path.
:return: a list(dict) containing label and template path
like ``[{'label': 'foo', 'template': 'path'}]``
"""
return []
class ImportMapping(models.Model):
""" mapping of previous column:field selections
This is useful when repeatedly importing from a third-party
system: column names generated by the external system may
not match Odoo's field names or labels. This model is used
to save the mapping between column names and fields so that
next time a user imports from the same third-party systems
we can automatically match the columns to the correct field
without them having to re-enter the mapping every single
time.
"""
_name = 'base_import.mapping'
res_model = fields.Char(index=True)
column_name = fields.Char()
field_name = fields.Char()
class Import(models.TransientModel): class Import(models.TransientModel):
@@ -251,15 +283,10 @@ class Import(models.TransientModel):
def _read_csv(self, options): def _read_csv(self, options):
""" Returns a CSV-parsed iterator of all non-empty lines in the file """ Returns a CSV-parsed iterator of all non-empty lines in the file
:throws csv.Error: if an error is detected during CSV parsing :throws csv.Error: if an error is detected during CSV parsing
:throws UnicodeDecodeError: if ``options.encoding`` is incorrect
""" """
csv_data = self.file csv_data = self.file
encoding = chardet.detect(csv_data)['encoding']
# TODO: guess encoding with chardet? Or https://github.com/aadsm/jschardet csv_data = csv_data.decode(encoding).encode('utf-8')
encoding = options.get('encoding', 'utf-8')
if encoding != 'utf-8':
# csv module expect utf-8, see http://docs.python.org/2/library/csv.html
csv_data = csv_data.decode(encoding).encode('utf-8')
csv_iterator = pycompat.csv_reader( csv_iterator = pycompat.csv_reader(
io.BytesIO(csv_data), io.BytesIO(csv_data),
@@ -376,7 +403,7 @@ class Import(models.TransientModel):
if results: if results:
return results return results
return ['id', 'text', 'char', 'datetime', 'selection', 'many2one', 'one2many', 'many2many', 'html'] return ['id', 'text', 'boolean', 'char', 'datetime', 'selection', 'many2one', 'one2many', 'many2many', 'html']
@api.model @api.model
def _find_type_from_preview(self, options, preview): def _find_type_from_preview(self, options, preview):
@@ -400,6 +427,7 @@ class Import(models.TransientModel):
:rtype: list(Field) :rtype: list(Field)
""" """
string_match = None string_match = None
IrTranslation = self.env['ir.translation']
for field in fields: for field in fields:
# FIXME: should match all translations & original # FIXME: should match all translations & original
# TODO: use string distance (levenshtein? hamming?) # TODO: use string distance (levenshtein? hamming?)
@@ -409,6 +437,9 @@ class Import(models.TransientModel):
# matching string are not reliable way because # matching string are not reliable way because
# strings have no unique constraint # strings have no unique constraint
string_match = field string_match = field
translated_header = IrTranslation._get_source('ir.model.fields,field_description', 'model', self.env.lang, header).lower()
if translated_header == field['string'].lower():
string_match = field
if string_match: if string_match:
# this behavior is only applied if there is no matching field['name'] # this behavior is only applied if there is no matching field['name']
return [string_match] return [string_match]
@@ -440,23 +471,31 @@ class Import(models.TransientModel):
Will consume the first line of the ``rows`` iterator. Will consume the first line of the ``rows`` iterator.
Returns a pair of (None, None) if headers were not requested Returns the list of headers and a dict mapping cell indices
or the list of headers and a dict mapping cell indices to key paths in the ``fields`` tree. If headers were not
to key paths in the ``fields`` tree requested, both collections are empty.
:param Iterator rows: :param Iterator rows:
:param dict fields: :param dict fields:
:param dict options: :param dict options:
:rtype: (None, None) | (list(str), dict(int: list(str))) :rtype: (list(str), dict(int: list(str)))
""" """
if not options.get('headers'): if not options.get('headers'):
return [], {} return [], {}
headers = next(rows) headers = next(rows)
return headers, { matches = {}
index: [field['name'] for field in self._match_header(header, fields, options)] or None mapping_records = self.env['base_import.mapping'].search_read([('res_model', '=', self.res_model)], ['column_name', 'field_name'])
for index, header in enumerate(headers) mapping_fields = {rec['column_name']: rec['field_name'] for rec in mapping_records}
} for index, header in enumerate(headers):
match_field = []
mapping_field_name = mapping_fields.get(header.lower())
if mapping_field_name:
match_field = mapping_field_name.split('/')
if not match_field:
match_field = [field['name'] for field in self._match_header(header, fields, options)]
matches[index] = match_field or None
return headers, matches
@api.multi @api.multi
def parse_preview(self, options, count=10): def parse_preview(self, options, count=10):
@@ -469,7 +508,7 @@ class Import(models.TransientModel):
:param int count: number of preview lines to generate :param int count: number of preview lines to generate
:param options: format-specific options. :param options: format-specific options.
CSV: {encoding, quoting, separator, headers} CSV: {quoting, separator, headers}
:type options: {str, str, str, bool} :type options: {str, str, str, bool}
:returns: {fields, matches, headers, preview} | {error, preview} :returns: {fields, matches, headers, preview} | {error, preview}
:rtype: {dict(str: dict(...)), dict(int, list(str)), list(str), list(list(str))} | {str, str} :rtype: {dict(str: dict(...)), dict(int, list(str)), list(str), list(list(str))} | {str, str}
@@ -482,14 +521,23 @@ class Import(models.TransientModel):
# Match should have consumed the first row (iif headers), get # Match should have consumed the first row (iif headers), get
# the ``count`` next rows for preview # the ``count`` next rows for preview
preview = list(itertools.islice(rows, count)) preview = list(itertools.islice(rows, count))
assert preview, "CSV file seems to have no content" assert preview, "file seems to have no content"
header_types = self._find_type_from_preview(options, preview) header_types = self._find_type_from_preview(options, preview)
if options.get('keep_matches', False) and len(options.get('fields', [])): if options.get('keep_matches') and len(options.get('fields', [])):
matches = {} matches = {}
for index, match in enumerate(options.get('fields')): for index, match in enumerate(options.get('fields')):
if match: if match:
matches[index] = match.split('/') matches[index] = match.split('/')
if options.get('keep_matches'):
advanced_mode = options.get('advanced')
else:
# Check is label contain relational field
has_relational_header = any(len(models.fix_import_export_id_paths(col)) > 1 for col in headers)
# Check is matches fields have relational field
has_relational_match = any(len(match) > 1 for field, match in matches.items() if match)
advanced_mode = has_relational_header or has_relational_match
return { return {
'fields': fields, 'fields': fields,
'matches': matches or False, 'matches': matches or False,
@@ -497,6 +545,7 @@ class Import(models.TransientModel):
'headers_type': header_types or False, 'headers_type': header_types or False,
'preview': preview, 'preview': preview,
'options': options, 'options': options,
'advanced_mode': advanced_mode,
'debug': self.user_has_groups('base.group_no_one'), 'debug': self.user_has_groups('base.group_no_one'),
} }
except Exception as error: except Exception as error:
@@ -505,7 +554,7 @@ class Import(models.TransientModel):
# preview to a list in the return. # preview to a list in the return.
_logger.debug("Error during parsing preview", exc_info=True) _logger.debug("Error during parsing preview", exc_info=True)
preview = None preview = None
if self.file_type == 'text/csv': if self.file_type == 'text/csv' and self.file:
preview = self.file[:ERROR_PREVIEW_BYTES].decode('iso-8859-1') preview = self.file[:ERROR_PREVIEW_BYTES].decode('iso-8859-1')
return { return {
'error': str(error), 'error': str(error),
@@ -627,7 +676,13 @@ class Import(models.TransientModel):
try: try:
line[index] = dt.strftime(dt.strptime(pycompat.to_native(line[index]), user_format), server_format) line[index] = dt.strftime(dt.strptime(pycompat.to_native(line[index]), user_format), server_format)
except ValueError as e: except ValueError as e:
raise ValueError(_("Column %s contains incorrect values. Error in line %d: %s") % (name, num + 1, e)) try:
# Allow to import date in datetime fields
if field['type'] == 'datetime':
user_format = pycompat.to_native(options.get('date_format'))
line[index] = dt.strftime(dt.strptime(pycompat.to_native(line[index]), user_format), server_format)
except ValueError as e:
raise ValueError(_("Column %s contains incorrect values. Error in line %d: %s") % (name, num + 1, e))
except Exception as e: except Exception as e:
raise ValueError(_("Error Parsing Date [%s:L%d]: %s") % (name, num + 1, e)) raise ValueError(_("Error Parsing Date [%s:L%d]: %s") % (name, num + 1, e))
# Check if the field is in import_field and is a relational (followed by /) # Check if the field is in import_field and is a relational (followed by /)
@@ -643,12 +698,14 @@ class Import(models.TransientModel):
return data return data
@api.multi @api.multi
def do(self, fields, options, dryrun=False): def do(self, fields, columns, options, dryrun=False):
""" Actual execution of the import """ Actual execution of the import
:param fields: import mapping: maps each column to a field, :param fields: import mapping: maps each column to a field,
``False`` for the columns to ignore ``False`` for the columns to ignore
:type fields: list(str|bool) :type fields: list(str|bool)
:param columns: columns label
:type columns: list(str|bool)
:param dict options: :param dict options:
:param bool dryrun: performs all import operations (and :param bool dryrun: performs all import operations (and
validations) but rollbacks writes, allows validations) but rollbacks writes, allows
@@ -661,7 +718,7 @@ class Import(models.TransientModel):
error message associated with the error (a string) error message associated with the error (a string)
and ``record`` the data which failed to import (or and ``record`` the data which failed to import (or
``false`` if that data isn't available or provided) ``false`` if that data isn't available or provided)
:rtype: list({type, message, record}) :rtype: dict(ids: list(int), messages: list({type, message, record}))
""" """
self.ensure_one() self.ensure_one()
self._cr.execute('SAVEPOINT import') self._cr.execute('SAVEPOINT import')
@@ -671,11 +728,13 @@ class Import(models.TransientModel):
# Parse date and float field # Parse date and float field
data = self._parse_import_data(data, import_fields, options) data = self._parse_import_data(data, import_fields, options)
except ValueError as error: except ValueError as error:
return [{ return {
'type': 'error', 'messages': [{
'message': pycompat.text_type(error), 'type': 'error',
'record': False, 'message': pycompat.text_type(error),
}] 'record': False,
}]
}
_logger.info('importing %d rows...', len(data)) _logger.info('importing %d rows...', len(data))
@@ -698,4 +757,20 @@ class Import(models.TransientModel):
except psycopg2.InternalError: except psycopg2.InternalError:
pass pass
return import_result['messages'] # Insert/Update mapping columns when import complete successfully
if import_result['ids'] and options.get('headers'):
BaseImportMapping = self.env['base_import.mapping']
for index, column_name in enumerate(columns):
if column_name:
# Update to latest selected field
exist_records = BaseImportMapping.search([('res_model', '=', self.res_model), ('column_name', '=', column_name)])
if exist_records:
exist_records.write({'field_name': fields[index]})
else:
BaseImportMapping.create({
'res_model': self.res_model,
'column_name': column_name,
'field_name': fields[index]
})
return import_result
@@ -12,4 +12,5 @@ access_base_import_tests_models_m2o_required_related,base.import.tests.models.m2
access_base_import_tests_models_o2m,base.import.tests.models.o2m,model_base_import_tests_models_o2m,base.group_user,1,1,1,1 access_base_import_tests_models_o2m,base.import.tests.models.o2m,model_base_import_tests_models_o2m,base.group_user,1,1,1,1
access_base_import_tests_models_o2m_child,base.import.tests.models.o2m.child,model_base_import_tests_models_o2m_child,base.group_user,1,1,1,1 access_base_import_tests_models_o2m_child,base.import.tests.models.o2m.child,model_base_import_tests_models_o2m_child,base.group_user,1,1,1,1
access_base_import_tests_models_preview,base.import.tests.models.preview,model_base_import_tests_models_preview,base.group_user,1,1,1,1 access_base_import_tests_models_preview,base.import.tests.models.preview,model_base_import_tests_models_preview,base.group_user,1,1,1,1
access_base_import_mapping,base.import.mapping,model_base_import_mapping,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
12 access_base_import_tests_models_o2m base.import.tests.models.o2m model_base_import_tests_models_o2m base.group_user 1 1 1 1
13 access_base_import_tests_models_o2m_child base.import.tests.models.o2m.child model_base_import_tests_models_o2m_child base.group_user 1 1 1 1
14 access_base_import_tests_models_preview base.import.tests.models.preview model_base_import_tests_models_preview base.group_user 1 1 1 1
15 access_base_import_mapping base.import.mapping model_base_import_mapping base.group_user 1 1 1 1
16
@@ -1,7 +0,0 @@
ID,Name,Company Type,Related Company,Customer,Supplier,Address Type,Street,Street2,City,State,ZIP,Country,Website,Phone,Mobile,Email
res_partner_1,Grocery Outlet,Company,,TRUE,FALSE,,51 Federal Street,#401,San Francisco,California,94107,United States,groceryoutlet.com,123456789,,
res_partner_2,Bob,Individual,Grocery Outlet,,,Contact,,,,,,,,,4157654321,
res_partner_3,Bill,Individual,Grocery Outlet,,,Other address,645 21st Street,,San Francisco,California,94110,United States,,,4517864512,
res_partner_4,Warehouse,Individual,Grocery Outlet,,,Shipping address,645 21st Street,,San Francisco,California,94110,United States,,415659874,,bill@odoo.com
res_partner_5,Accounting dep.,Individual,Grocery Outlet,,,Invoice address,51 Federal Street,#404,San Francisco,California,94107,United States,,123456780,,
res_partner_6,Agrolait,Company,,TRUE,FALSE,,3442 2nd Avenue,,New York,New York,11201,United States,agrolait.com,654978123,,
1 ID Name Company Type Related Company Customer Supplier Address Type Street Street2 City State ZIP Country Website Phone Mobile Email
2 res_partner_1 Grocery Outlet Company TRUE FALSE 51 Federal Street #401 San Francisco California 94107 United States groceryoutlet.com 123456789
3 res_partner_2 Bob Individual Grocery Outlet Contact 4157654321
4 res_partner_3 Bill Individual Grocery Outlet Other address 645 21st Street San Francisco California 94110 United States 4517864512
5 res_partner_4 Warehouse Individual Grocery Outlet Shipping address 645 21st Street San Francisco California 94110 United States 415659874 bill@odoo.com
6 res_partner_5 Accounting dep. Individual Grocery Outlet Invoice address 51 Federal Street #404 San Francisco California 94107 United States 123456780
7 res_partner_6 Agrolait Company TRUE FALSE 3442 2nd Avenue New York New York 11201 United States agrolait.com 654978123
@@ -47,7 +47,6 @@ function jsonp(form, attributes, callback) {
var DataImport = AbstractAction.extend(ControlPanelMixin, { var DataImport = AbstractAction.extend(ControlPanelMixin, {
template: 'ImportView', template: 'ImportView',
opts: [ opts: [
{name: 'encoding', label: _lt("Encoding:"), value: 'utf-8'},
{name: 'separator', label: _lt("Separator:"), value: ','}, {name: 'separator', label: _lt("Separator:"), value: ','},
{name: 'quoting', label: _lt("Text Delimiter:"), value: '"'} {name: 'quoting', label: _lt("Text Delimiter:"), value: '"'}
], ],
@@ -60,7 +59,6 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
events: { events: {
// 'change .oe_import_grid input': 'import_dryrun', // 'change .oe_import_grid input': 'import_dryrun',
'change .oe_import_file': 'loaded_file', 'change .oe_import_file': 'loaded_file',
'click .oe_import_file_reload': 'loaded_file',
'change input.oe_import_has_header, .js_import_options input': 'settings_changed', 'change input.oe_import_has_header, .js_import_options input': 'settings_changed',
'change input.oe_import_advanced_mode': function (e) { 'change input.oe_import_advanced_mode': function (e) {
this.do_not_change_match = true; this.do_not_change_match = true;
@@ -111,9 +109,21 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
action.display_name = _t('Import a File'); // Displayed in the breadcrumbs action.display_name = _t('Import a File'); // Displayed in the breadcrumbs
this.do_not_change_match = false; this.do_not_change_match = false;
}, },
/**
* @override
*/
willStart: function () {
var self = this;
return this._rpc({
model: this.res_model,
method: 'get_import_templates',
context: this.parent_context,
}).then(function (result) {
self.importTemplates = result;
});
},
start: function () { start: function () {
var self = this; var self = this;
this.setup_encoding_picker();
this.setup_separator_picker(); this.setup_separator_picker();
this.setup_float_format_picker(); this.setup_float_format_picker();
@@ -124,7 +134,6 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
self.$('input[name=import_id]').val(id); self.$('input[name=import_id]').val(id);
self.renderButtons(); self.renderButtons();
self.renderImportLink();
var status = { var status = {
cp_content: {$buttons: self.$buttons}, cp_content: {$buttons: self.$buttons},
}; };
@@ -145,36 +154,15 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
this.$buttons = $(QWeb.render("ImportView.buttons", this)); this.$buttons = $(QWeb.render("ImportView.buttons", this));
this.$buttons.filter('.o_import_validate').on('click', this.validate.bind(this)); this.$buttons.filter('.o_import_validate').on('click', this.validate.bind(this));
this.$buttons.filter('.o_import_import').on('click', this.import.bind(this)); this.$buttons.filter('.o_import_import').on('click', this.import.bind(this));
this.$buttons.filter('.o_import_file_reload').on('click', this.loaded_file.bind(this));
this.$buttons.filter('.oe_import_file').on('click', function () {
self.$('.oe_import_file').click();
});
this.$buttons.filter('.o_import_cancel').on('click', function(e) { this.$buttons.filter('.o_import_cancel').on('click', function(e) {
e.preventDefault(); e.preventDefault();
self.exit(); self.exit();
}); });
}, },
renderImportLink: function() {
if (this.res_model == 'res.partner') {
this.$(".import-link").prop({"text": _t(" Import Template for Customers"), "href": "/base_import/static/csv/res.partner.csv"});
this.$(".template-import").removeClass("hidden");
}
},
setup_encoding_picker: function () {
this.$('input.oe_import_encoding').select2({
width: '160px',
query: function (q) {
var make = function (term) { return {id: term, text: term}; };
var suggestions = _.map(
('utf-8 utf-16 windows-1252 latin1 latin2 big5 ' +
'gb18030 shift_jis windows-1251 koir8_r').split(/\s+/),
make);
if (q.term) {
suggestions.unshift(make(q.term));
}
q.callback({results: suggestions});
},
initSelection: function (e, c) {
return c({id: 'utf-8', text: 'utf-8'});
}
}).select2('val', 'utf-8');
},
setup_separator_picker: function () { setup_separator_picker: function () {
this.$('input.oe_import_separator').select2({ this.$('input.oe_import_separator').select2({
width: '160px', width: '160px',
@@ -196,26 +184,33 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
}); });
}, },
setup_float_format_picker: function () { setup_float_format_picker: function () {
var sug_query = function (q) { var sub_query = function (q, options) {
var suggestions = [ var suggestions = [
{id: ',', text: _t("Comma")}, {id: ',', text: _t("Comma")},
{id: '.', text: _t("Dot")}, {id: '.', text: _t("Dot")},
]; ];
if (q.term) { if (options && options.no_separator) {
suggestions.unshift({id: q.term, text: q.term}); suggestions.push({id: '', text: _t("No Separator")});
} }
q.callback({results: suggestions}); if (q.term) {
}; suggestions.unshift({id: q.term, text: q.term});
}
q.callback({results: suggestions});
};
this.$('input.oe_import_float_thousand_separator').select2({ this.$('input.oe_import_float_thousand_separator').select2({
width: '160px', width: '160px',
query: sug_query, query: function (q) {
sub_query(q, {no_separator: true})
},
initSelection: function (e, c) { initSelection: function (e, c) {
return c({id: ',', text: _t("Comma")}); return c({id: ',', text: _t("Comma")});
}, },
}); });
this.$('input.oe_import_float_decimal_separator').select2({ this.$('input.oe_import_float_decimal_separator').select2({
width: '160px', width: '160px',
query: sug_query, query: function (q) {
sub_query(q);
},
initSelection: function (e, c) { initSelection: function (e, c) {
return c({id: ',', text: _t("Dot")}); return c({id: ',', text: _t("Dot")});
}, },
@@ -254,9 +249,7 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
//- File & settings change section //- File & settings change section
onfile_loaded: function () { onfile_loaded: function () {
var file = this.$('.oe_import_file')[0].files[0]; var file = this.$('.oe_import_file')[0].files[0];
this.$('.oe_import_file_show').val(file !== undefined && file.name || ''); this.$buttons.filter('.o_import_import, .o_import_validate, .o_import_file_reload').addClass('hidden');
this.$buttons.filter('.o_import_button').add(this.$('.oe_import_file_reload'))
.prop('disabled', true);
if (!this.$('input.oe_import_file').val()) { return this['settings_changed'](); } if (!this.$('input.oe_import_file').val()) { return this['settings_changed'](); }
this.$('.oe_import_date_format').val(''); this.$('.oe_import_date_format').val('');
this.$('.oe_import_datetime_format').val(''); this.$('.oe_import_datetime_format').val('');
@@ -268,15 +261,14 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
if ((file.type && _.last(file.type.split('/')) === "csv") || ( _.last(file.name.split('.')) === "csv")) { if ((file.type && _.last(file.type.split('/')) === "csv") || ( _.last(file.name.split('.')) === "csv")) {
import_toggle = true; import_toggle = true;
} }
this.$el.find('.oe_import_toggle').toggle(import_toggle); this.$el.find('.oe_import_box').toggle(import_toggle);
jsonp(this.$el, { jsonp(this.$el, {
url: '/base_import/set_file' url: '/base_import/set_file'
}, this.proxy('settings_changed')); }, this.proxy('settings_changed'));
}, },
onpreviewing: function () { onpreviewing: function () {
var self = this; var self = this;
this.$buttons.filter('.o_import_button').add(this.$('.oe_import_file_reload')) this.$buttons.filter('.o_import_import, .o_import_validate, .o_import_file_reload').addClass('hidden');
.prop('disabled', true);
this.$el.addClass('oe_import_with_file'); this.$el.addClass('oe_import_with_file');
// TODO: test that write // succeeded? // TODO: test that write // succeeded?
this.$el.removeClass('oe_import_preview_error oe_import_error'); this.$el.removeClass('oe_import_preview_error oe_import_error');
@@ -295,26 +287,32 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
}, },
onpreview_error: function (event, from, to, result) { onpreview_error: function (event, from, to, result) {
this.$('.oe_import_options').show(); this.$('.oe_import_options').show();
this.$('.oe_import_file_reload').prop('disabled', false); this.$buttons.filter('.o_import_file_reload').removeClass('hidden');
this.$el.addClass('oe_import_preview_error oe_import_error'); this.$el.addClass('oe_import_preview_error oe_import_error');
this.$el.find('.oe_import_box, .oe_import_with_file').removeClass('hidden');
this.$el.find('.o_view_nocontent').addClass('hidden');
this.$('.oe_import_error_report').html( this.$('.oe_import_error_report').html(
QWeb.render('ImportView.preview.error', result)); QWeb.render('ImportView.preview.error', result));
}, },
onpreview_success: function (event, from, to, result) { onpreview_success: function (event, from, to, result) {
var self = this; var self = this;
this.$buttons.filter('.o_import_import').removeClass('btn-primary'); this.$buttons.filter('.oe_import_file')
this.$buttons.filter('.o_import_validate').addClass('btn-primary'); .text(_t('Load New File'))
this.$buttons.filter('.o_import_button').add(this.$('.oe_import_file_reload')) .removeClass('btn-primary').addClass('btn-default')
.prop('disabled', false); .blur();
this.$buttons.filter('.o_import_import, .o_import_validate, .o_import_file_reload').removeClass('hidden');
this.$el.find('.oe_import_box, .oe_import_with_file').removeClass('hidden');
this.$el.find('.o_view_nocontent').addClass('hidden');
this.$el.addClass('oe_import_preview'); this.$el.addClass('oe_import_preview');
this.$('input.oe_import_advanced_mode').prop('checked', result.advanced_mode);
this.$('.oe_import_grid').html(QWeb.render('ImportView.preview', result)); this.$('.oe_import_grid').html(QWeb.render('ImportView.preview', result));
if (result.headers.length === 1) { if (result.headers.length === 1) {
this.$('.oe_import_options').show(); this.$('.oe_import_options').show();
this.onresults(null, null, null, [{ this.onresults(null, null, null, {'messages': [{
type: 'warning', type: 'warning',
message: _t("A single column was found in the file, this often means the file separator is incorrect") message: _t("A single column was found in the file, this often means the file separator is incorrect")
}]); }]});
} }
this.$('.oe_import_date_format').val(time.strftime_to_moment_format(result.options.date_format)); this.$('.oe_import_date_format').val(time.strftime_to_moment_format(result.options.date_format));
@@ -461,6 +459,9 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
var fields = this.$('.oe_import_fields input.oe_import_match_field').map(function (index, el) { var fields = this.$('.oe_import_fields input.oe_import_match_field').map(function (index, el) {
return $(el).select2('val') || false; return $(el).select2('val') || false;
}).get(); }).get();
var columns = this.$('.oe_import_grid-header .oe_import_grid-cell .o_import_header_name').map(function () {
return $(this).text().trim().toLowerCase() || false;
}).get();
var tracking_disable = 'tracking_disable' in kwargs ? kwargs.tracking_disable : !this.$('#oe_import_tracking').prop('checked') var tracking_disable = 'tracking_disable' in kwargs ? kwargs.tracking_disable : !this.$('#oe_import_tracking').prop('checked')
var defer_parent_store = 'defer_parent_store' in kwargs ? kwargs.defer_parent_store : !!this.$('#oe_import_deferparentstore').prop('checked') var defer_parent_store = 'defer_parent_store' in kwargs ? kwargs.defer_parent_store : !!this.$('#oe_import_deferparentstore').prop('checked')
delete kwargs.tracking_disable; delete kwargs.tracking_disable;
@@ -472,18 +473,18 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
return this._rpc({ return this._rpc({
model: 'base_import.import', model: 'base_import.import',
method: 'do', method: 'do',
args: [this.id, fields, this.import_options()], args: [this.id, fields, columns, this.import_options()],
kwargs : kwargs, kwargs : kwargs,
}).fail(function (error, event) { }).fail(function (error, event) {
// In case of unexpected exception, convert // In case of unexpected exception, convert
// "JSON-RPC error" to an import failure, and // "JSON-RPC error" to an import failure, and
// prevent default handling (warning dialog) // prevent default handling (warning dialog)
if (event) { event.preventDefault(); } if (event) { event.preventDefault(); }
return $.when([{ return $.when({'messages': [{
type: 'error', type: 'error',
record: false, record: false,
message: error.data.arguments && error.data.arguments[1] || error.message, message: error.data.arguments && error.data.arguments[1] || error.message,
}]); }]});
}) ; }) ;
}, },
onvalidate: function () { onvalidate: function () {
@@ -492,27 +493,26 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
}, },
onimport: function () { onimport: function () {
var self = this; var self = this;
return this.call_import({ dryrun: false }).done(function (message) { return this.call_import({ dryrun: false }).done(function (results) {
var message = results.messages;
if (!_.any(message, function (message) { if (!_.any(message, function (message) {
return message.type === 'error'; })) { return message.type === 'error'; })) {
self['import_succeeded'](); self['import_succeeded'](results);
return; return;
} }
self['import_failed'](message); self['import_failed'](results);
}); });
}, },
onimported: function () { onimported: function (event, from, to, results) {
this.do_notify(_t("Import completed"), _.str.sprintf(_t("%d records were successfully imported"), results.ids.length));
this.exit(); this.exit();
}, },
exit: function () { exit: function () {
this.trigger_up('history_back'); this.trigger_up('history_back');
}, },
onresults: function (event, from, to, message) { onresults: function (event, from, to, results) {
var message = results.messages;
var no_messages = _.isEmpty(message); var no_messages = _.isEmpty(message);
this.$buttons.filter('.o_import_import').toggleClass('btn-primary', no_messages);
this.$buttons.filter('.o_import_import').toggleClass('btn-default', !no_messages);
this.$buttons.filter('.o_import_validate').toggleClass('btn-primary', !no_messages);
this.$buttons.filter('.o_import_validate').toggleClass('btn-default', no_messages);
if (no_messages) { if (no_messages) {
message.push({ message.push({
type: 'info', type: 'info',
@@ -591,7 +591,7 @@ StateMachine.create({
{ name: 'import', from: ['preview_success', 'results'], to: 'importing' }, { name: 'import', from: ['preview_success', 'results'], to: 'importing' },
{ name: 'import_succeeded', from: 'importing', to: 'imported'}, { name: 'import_succeeded', from: 'importing', to: 'imported'},
{ name: 'import_failed', from: 'importing', to: 'results' } { name: 'import_failed', from: 'importing', to: 'results' }
] ],
}); });
return { return {
+7 -5
View File
@@ -1,6 +1,5 @@
.oe_import { .oe_import {
@include o-webclient-padding($top: 8px); @include o-webclient-padding($top: 8px);
background-color: white;
overflow: auto; overflow: auto;
position: absolute; // Needed for chrome position: absolute; // Needed for chrome
top: 0; top: 0;
@@ -27,7 +26,7 @@
} }
.oe_import_box { .oe_import_box {
padding: 16px; padding: 8px;
background: #F0EEEE; background: #F0EEEE;
border-radius: $border-radius-base; border-radius: $border-radius-base;
border: solid 1px #dddddd; border: solid 1px #dddddd;
@@ -39,9 +38,6 @@
} }
} }
.oe_import_toggle {
margin-top: 8px;
}
a.oe_import_toggle { a.oe_import_toggle {
display: block; display: block;
&:before { &:before {
@@ -94,6 +90,12 @@
margin: 2px 0; margin: 2px 0;
list-style: none; list-style: none;
border-radius: $border-radius-base; border-radius: $border-radius-base;
&.bg-error {
@extend .bg-danger;
}
&.text-error {
@extend .text-danger;
}
} }
} }
@@ -6,34 +6,12 @@
<input type="hidden" name="session_id" <input type="hidden" name="session_id"
t-att-value="widget.session.session_id"/> t-att-value="widget.session.session_id"/>
<input type="hidden" name="import_id"/> <input type="hidden" name="import_id"/>
<div class="oe_import_box col-sm-9"> <div class="oe_import_box col-md-12 hidden">
<div class="col-sm-12"> <input accept=".csv, .xls, .xlsx, .xlsm, .ods" t-attf-id="file_#{_id}"
<p>Select a CSV or Excel file to import. <a href="https://www.odoo.com/documentation/user/11.0/general/base_import/import_faq.html" target="new" class="pull-right">Help</a></p> name="file" class="oe_import_file" type="file" style="display:none;"/>
</div> <div class="oe_import_with_file">
<div class="col-sm-10">
<div class="input-group">
<input type="text" class="oe_import_file_show form-control" placeholder="No file chosen..."/>
<span class="input-group-btn">
<button class="btn btn-primary">
<input accept=".csv, .xls, .xlsx, .xlsm, .ods" id-attf-id="file_#{_id}"
name="file" id="my-file-selector" class="oe_import_file" type="file" style="display:none;"/>
Load File
</button>
</span>
<span class="input-group-btn">
<button type="button" class="btn btn-default oe_import_file_reload" disabled="disabled">Reload File</button>
</span>
</div>
<div class="template-import mt24 hidden">
<h5>
<a href="#" class="import-link fa fa-download" aria-label="Download" title="Download"></a>
</h5>
</div>
</div>
<div class="oe_import_with_file col-sm-12">
<a href="#" class="oe_import_toggle"> <a href="#" class="oe_import_toggle">
Options…</a> Formatting Options…</a>
<div class="oe_import_toggled oe_import_options js_import_options col-sm-5"> <div class="oe_import_toggled oe_import_options js_import_options col-sm-5">
<p t-foreach="widget.opts" t-as="option"> <p t-foreach="widget.opts" t-as="option">
<!-- no @name, avoid submission when file_update called --> <!-- no @name, avoid submission when file_update called -->
@@ -57,7 +35,7 @@
</div> </div>
</div> </div>
<div class="oe_import_with_file oe_padding col-sm-12"> <div class="oe_import_with_file oe_padding col-sm-12 hidden">
<h2>Map your columns to import</h2> <h2>Map your columns to import</h2>
<div title="If the model uses openchatter, history tracking <div title="If the model uses openchatter, history tracking
will set up subscriptions and send notifications will set up subscriptions and send notifications
@@ -90,16 +68,35 @@
simpler especially when the file has many columns.</p> simpler especially when the file has many columns.</p>
<div class="oe_import_error_report"></div> <div class="oe_import_error_report"></div>
<table class="table-striped table-bordered oe_import_grid" /> <div class="table-responsive">
<table class="table-striped table-bordered oe_import_grid bg-white" />
</div>
</div>
<div class="o_view_nocontent">
<div class="o_nocontent_help">
<p class="o_view_nocontent_smiling_face">
Select a CSV or Excel file to import.
</p>
<p>
Excel files are recommended as fields formatting is automatic.
</p>
<div class="mt16 mb4">Need Help?</div>
<div t-foreach="widget.importTemplates" t-as="template">
<a t-att-href="template.template" aria-label="Download" title="Download">
<i class="fa fa-download"/> <span><t t-esc="template.label"/></span>
</a>
</div>
<a href="https://www.odoo.com/documentation/user/11.0/general/base_import/import_faq.html" target="new">Import FAQ</a>
</div>
</div> </div>
</form> </form>
</t> </t>
<t t-name="ImportView.buttons"> <t t-name="ImportView.buttons">
<button type="button" class="btn btn-sm btn-primary o_import_button o_import_validate" <button type="button" class="btn btn-sm btn-primary o_import_import hidden">Import</button>
disabled="disabled">Test Import</button> <button type="button" class="btn btn-sm btn-default o_import_validate hidden">Test Import</button>
<button type="button" class="btn btn-sm btn-default o_import_button o_import_import" <button type="button" class="btn btn-sm btn-primary oe_import_file">Load File</button>
disabled="disabled">Import</button> <button type="button" class="btn btn-sm btn-default o_import_file_reload hidden">Reload File</button>
<button type="button" class="btn btn-sm btn-default o_import_cancel">Cancel</button> <button type="button" class="btn btn-sm btn-default o_import_cancel">Cancel</button>
</t> </t>
@@ -107,7 +104,7 @@
<thead> <thead>
<tr t-if="headers" class="oe_import_grid-header"> <tr t-if="headers" class="oe_import_grid-header">
<td t-foreach="headers" t-as="header" class="oe_import_grid-cell" <td t-foreach="headers" t-as="header" class="oe_import_grid-cell"
><t t-esc="header"/></td> ><span class="o_import_header_name"><t t-esc="header"/></span></td>
</tr> </tr>
<tr class="oe_import_fields"> <tr class="oe_import_fields">
<!-- Iterate on first row to ensure we have all columns --> <!-- Iterate on first row to ensure we have all columns -->
@@ -126,7 +123,7 @@
<t t-name="ImportView.preview.error"> <t t-name="ImportView.preview.error">
<div class="oe_import_report bg-danger text-danger"> <div class="oe_import_report bg-danger text-danger">
<p>Import preview failed due to: <t t-esc="error"/>.</p> <p>Import preview failed due to: <t t-esc="error"/>.</p>
<p>For CSV files, the issue could be an incorrect encoding.</p> <p>For CSV files, you may need to select the correct separator.</p>
<p t-if="preview">Here is the start of the file we could not import:</p> <p t-if="preview">Here is the start of the file we could not import:</p>
</div> </div>
<pre t-if="preview"><t t-esc="preview"/></pre> <pre t-if="preview"><t t-esc="preview"/></pre>
+34 -9
View File
@@ -189,6 +189,31 @@ class TestMatchHeadersMultiple(TransactionCase):
) )
class TestColumnMapping(TransactionCase):
def test_column_mapping(self):
import_record = self.env['base_import.import'].create({
'res_model': 'base_import.tests.models.preview',
'file': u"Name,Some Value,value\n"
u"chhagan,10,1\n"
u"magan,20,2\n".encode('utf-8'),
'file_type': 'text/csv',
'file_name': 'data.csv',
})
import_record.do(
['name', 'somevalue', 'othervalue'],
['Name', 'Some Value', 'value'],
{'quoting': '"', 'separator': ',', 'headers': True},
True
)
fields = self.env['base_import.mapping'].search_read(
[('res_model', '=', 'base_import.tests.models.preview')],
['column_name', 'field_name']
)
self.assertItemsEqual([f['column_name'] for f in fields], ['Name', 'Some Value', 'value'])
self.assertItemsEqual([f['field_name'] for f in fields], ['somevalue', 'name', 'othervalue'])
class TestPreview(TransactionCase): class TestPreview(TransactionCase):
def make_import(self): def make_import(self):
@@ -207,7 +232,7 @@ class TestPreview(TransactionCase):
'quoting': '"', 'quoting': '"',
'separator': ',', 'separator': ',',
}) })
self.assertTrue('error' in result) self.assertFalse('error' in result)
@mute_logger('odoo.addons.base_import.models.base_import') @mute_logger('odoo.addons.base_import.models.base_import')
def test_csv_errors(self): def test_csv_errors(self):
@@ -216,14 +241,12 @@ class TestPreview(TransactionCase):
result = import_wizard.parse_preview({ result = import_wizard.parse_preview({
'quoting': 'foo', 'quoting': 'foo',
'separator': ',', 'separator': ',',
'encoding': 'euc_kr',
}) })
self.assertTrue('error' in result) self.assertTrue('error' in result)
result = import_wizard.parse_preview({ result = import_wizard.parse_preview({
'quoting': '"', 'quoting': '"',
'separator': 'bob', 'separator': 'bob',
'encoding': 'euc_kr',
}) })
self.assertTrue('error' in result) self.assertTrue('error' in result)
@@ -258,7 +281,7 @@ class TestPreview(TransactionCase):
['qux', '5', '6'], ['qux', '5', '6'],
]) ])
# Ensure we only have the response fields we expect # Ensure we only have the response fields we expect
self.assertItemsEqual(list(result), ['matches', 'headers', 'fields', 'preview', 'headers_type', 'options', 'debug']) self.assertItemsEqual(list(result), ['matches', 'headers', 'fields', 'preview', 'headers_type', 'options', 'advanced_mode', 'debug'])
@unittest.skipUnless(can_import('xlrd'), "XLRD module not available") @unittest.skipUnless(can_import('xlrd'), "XLRD module not available")
def test_xls_success(self): def test_xls_success(self):
@@ -288,7 +311,7 @@ class TestPreview(TransactionCase):
['qux', '5', '6'], ['qux', '5', '6'],
]) ])
# Ensure we only have the response fields we expect # Ensure we only have the response fields we expect
self.assertItemsEqual(list(result), ['matches', 'headers', 'fields', 'preview', 'headers_type', 'options', 'debug']) self.assertItemsEqual(list(result), ['matches', 'headers', 'fields', 'preview', 'headers_type', 'options', 'advanced_mode', 'debug'])
@unittest.skipUnless(can_import('xlrd.xlsx'), "XLRD/XLSX not available") @unittest.skipUnless(can_import('xlrd.xlsx'), "XLRD/XLSX not available")
def test_xlsx_success(self): def test_xlsx_success(self):
@@ -318,7 +341,7 @@ class TestPreview(TransactionCase):
['qux', '5', '6'], ['qux', '5', '6'],
]) ])
# Ensure we only have the response fields we expect # Ensure we only have the response fields we expect
self.assertItemsEqual(list(result), ['matches', 'headers', 'fields', 'preview', 'headers_type', 'options', 'debug']) self.assertItemsEqual(list(result), ['matches', 'headers', 'fields', 'preview', 'headers_type', 'options', 'advanced_mode', 'debug'])
@unittest.skipUnless(can_import('odf'), "ODFPY not available") @unittest.skipUnless(can_import('odf'), "ODFPY not available")
def test_ods_success(self): def test_ods_success(self):
@@ -348,7 +371,7 @@ class TestPreview(TransactionCase):
['aux', '5', '6'], ['aux', '5', '6'],
]) ])
# Ensure we only have the response fields we expect # Ensure we only have the response fields we expect
self.assertItemsEqual(list(result), ['matches', 'headers', 'fields', 'preview', 'headers_type', 'options', 'debug']) self.assertItemsEqual(list(result), ['matches', 'headers', 'fields', 'preview', 'headers_type', 'options', 'advanced_mode', 'debug'])
class test_convert_import_data(TransactionCase): class test_convert_import_data(TransactionCase):
@@ -388,6 +411,7 @@ class test_convert_import_data(TransactionCase):
results = import_wizard.do( results = import_wizard.do(
['name', 'date', 'create_date'], ['name', 'date', 'create_date'],
[],
{ {
'date_format': '%Y年%m月%d日', 'date_format': '%Y年%m月%d日',
'datetime_format': '%Y-%m-%d %H:%M', 'datetime_format': '%Y-%m-%d %H:%M',
@@ -398,7 +422,7 @@ class test_convert_import_data(TransactionCase):
) )
# if results empty, no errors # if results empty, no errors
self.assertItemsEqual(results, []) self.assertItemsEqual(results['messages'], [])
def test_parse_relational_fields(self): def test_parse_relational_fields(self):
""" Ensure that relational fields float and date are correctly """ Ensure that relational fields float and date are correctly
@@ -570,5 +594,6 @@ class test_failures(TransactionCase):
}) })
results = import_wizard.do( results = import_wizard.do(
['name', 'db_datas'], ['name', 'db_datas'],
[],
{'headers': True, 'separator': ',', 'quoting': '"'}) {'headers': True, 'separator': ',', 'quoting': '"'})
self.assertFalse(results, "results should be empty on successful import") self.assertFalse(results['messages'], "results should be empty on successful import")
+7
View File
@@ -1212,6 +1212,13 @@ class Lead(models.Model):
break break
return result return result
@api.model
def get_import_templates(self):
return [{
'label': _('Import Template for Leads & Opportunities'),
'template': '/crm/static/xls/crm_lead.xls'
}]
class Tag(models.Model): class Tag(models.Model):
Binary file not shown.
+7
View File
@@ -273,6 +273,13 @@ class Employee(models.Model):
except AccessError: except AccessError:
employee.is_address_home_a_company = False employee.is_address_home_a_company = False
@api.model
def get_import_templates(self):
return [{
'label': _('Import Template for Employees'),
'template': '/hr/static/xls/hr_employee.xls'
}]
class Department(models.Model): class Department(models.Model):
_name = "hr.department" _name = "hr.department"
Binary file not shown.
+7
View File
@@ -175,6 +175,13 @@ class MrpBom(models.Model):
return boms_done, lines_done return boms_done, lines_done
@api.model
def get_import_templates(self):
return [{
'label': _('Import Template for Bills of Materials'),
'template': '/mrp/static/xls/mrp_bom.xls'
}]
class MrpBomLine(models.Model): class MrpBomLine(models.Model):
_name = 'mrp.bom.line' _name = 'mrp.bom.line'
Binary file not shown.
-1
View File
@@ -37,7 +37,6 @@ Print product labels with barcode.
'views/product_template_views.xml', 'views/product_template_views.xml',
'views/product_pricelist_views.xml', 'views/product_pricelist_views.xml',
'views/res_partner_views.xml', 'views/res_partner_views.xml',
'views/product_template_import_templates.xml',
'views/res_users_views.xml', 'views/res_users_views.xml',
'report/product_reports.xml', 'report/product_reports.xml',
'report/product_pricelist_templates.xml', 'report/product_pricelist_templates.xml',
+7
View File
@@ -630,3 +630,10 @@ class SupplierInfo(models.Model):
delay = fields.Integer( delay = fields.Integer(
'Delivery Lead Time', default=1, required=True, 'Delivery Lead Time', default=1, required=True,
help="Lead time in days between the confirmation of the purchase order and the receipt of the products in your warehouse. Used by the scheduler for automatic computation of the purchase order planning.") help="Lead time in days between the confirmation of the purchase order and the receipt of the products in your warehouse. Used by the scheduler for automatic computation of the purchase order planning.")
@api.model
def get_import_templates(self):
return [{
'label': _('Import Template for Vendor Pricelists'),
'template': '/product/static/xls/product_supplierinfo.xls'
}]
+7 -1
View File
@@ -333,6 +333,13 @@ class Pricelist(models.Model):
return pl return pl
@api.model
def get_import_templates(self):
return [{
'label': _('Import Template for Pricelists'),
'template': '/product/static/xls/product_pricelist.xls'
}]
class ResCountryGroup(models.Model): class ResCountryGroup(models.Model):
_inherit = 'res.country.group' _inherit = 'res.country.group'
@@ -470,4 +477,3 @@ class PricelistItem(models.Model):
'price_min_margin': 0.0, 'price_min_margin': 0.0,
'price_max_margin': 0.0, 'price_max_margin': 0.0,
}) })
@@ -483,3 +483,10 @@ class ProductTemplate(models.Model):
empty_list_help_document_name=_("product"), empty_list_help_document_name=_("product"),
) )
return super(ProductTemplate, self).get_empty_list_help(help) return super(ProductTemplate, self).get_empty_list_help(help)
@api.model
def get_import_templates(self):
return [{
'label': _('Import Template for Products'),
'template': '/product/static/xls/product_template.xls'
}]
@@ -1,4 +0,0 @@
ID,Name,Product Type,Internal Reference,Barcode,Sales Price,Cost,Weight,Sale Description
product_template_1,Pedal Bin,Consumable,AK789,,29.99,12,,
product_template_2,Dell Inspiron Laptop without OS,Consumable,DELL15,,2499,1643,,2 years warranty
product_template_3,Prepaid Consulting,Service,,,90,40,,
1 ID Name Product Type Internal Reference Barcode Sales Price Cost Weight Sale Description
2 product_template_1 Pedal Bin Consumable AK789 29.99 12
3 product_template_2 Dell Inspiron Laptop without OS Consumable DELL15 2499 1643 2 years warranty
4 product_template_3 Prepaid Consulting Service 90 40
@@ -1,19 +0,0 @@
odoo.define('product_template_import.import', function (require) {
"use strict";
var core = require('web.core');
var BaseImport = require('base_import.import');
var _t = core._t;
BaseImport.DataImport.include({
renderImportLink: function() {
this._super();
if (this.res_model == 'product.template') {
this.$(".import-link").prop({"text": _t(" Import Template for Products"), "href": "/product/static/csv/product.template.csv"});
this.$(".template-import").removeClass("hidden");
}
},
});
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" name="product_template_import assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/product/static/src/js/product_template_import.js"></script>
</xpath>
</template>
</odoo>
+11 -1
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models from odoo import api, fields, models, _
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
@@ -27,6 +27,16 @@ class ProductTemplate(models.Model):
purchase_line_warn = fields.Selection(WARNING_MESSAGE, 'Purchase Order Line', help=WARNING_HELP, required=True, default="no-message") purchase_line_warn = fields.Selection(WARNING_MESSAGE, 'Purchase Order Line', help=WARNING_HELP, required=True, default="no-message")
purchase_line_warn_msg = fields.Text('Message for Purchase Order Line') purchase_line_warn_msg = fields.Text('Message for Purchase Order Line')
@api.model
def get_import_templates(self):
res = super(ProductTemplate, self).get_import_templates()
if self.env.context.get('purchase_product_template'):
return [{
'label': _('Import Template for Products'),
'template': '/purchase/static/xls/product_purchase.xls'
}]
return res
class ProductProduct(models.Model): class ProductProduct(models.Model):
_name = 'product.product' _name = 'product.product'
Binary file not shown.
+1 -1
View File
@@ -55,7 +55,7 @@
<field name="res_model">product.template</field> <field name="res_model">product.template</field>
<field name="view_type">form</field> <field name="view_type">form</field>
<field name="view_mode">kanban,tree,form</field> <field name="view_mode">kanban,tree,form</field>
<field name="context">{"search_default_filter_to_purchase":1}</field> <field name="context">{"search_default_filter_to_purchase":1, "purchase_product_template": 1}</field>
<field name="search_view_id" eval="False"/> <!-- Force empty --> <field name="search_view_id" eval="False"/> <!-- Force empty -->
<field name="view_id" eval="False"/> <!-- Force empty --> <field name="view_id" eval="False"/> <!-- Force empty -->
<field name="help" type="html"> <field name="help" type="html">
+16 -1
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models from odoo import api, fields, models, _
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
@@ -59,3 +59,18 @@ class ProductTemplate(models.Model):
if self.type == 'consu': if self.type == 'consu':
self.invoice_policy = 'order' self.invoice_policy = 'order'
self.service_type = 'manual' self.service_type = 'manual'
@api.model
def get_import_templates(self):
res = super(ProductTemplate, self).get_import_templates()
if self.env.context.get('sale_multi_pricelist_product_template'):
sale_pricelist_setting = self.env['ir.config_parameter'].sudo().get_param('sale.sale_pricelist_setting')
if sale_pricelist_setting == 'percentage':
return [{
'label': _('Import Template for Products'),
'template': '/product/static/xls/product_template.xls'
}, {
'label': _('Import Template for Products (with several prices)'),
'template': '/sale/static/xls/product_pricelist_several.xls'
}]
return res
Binary file not shown.
+20 -2
View File
@@ -61,9 +61,27 @@
parent="menu_sale_report" parent="menu_sale_report"
sequence="2"/> sequence="2"/>
<menuitem id="product_menu_catalog" name="Products" action="product.product_template_action" parent="sale_menu_root" sequence="4" groups="sales_team.group_sale_salesman"/> <record id="product_template_action" model="ir.actions.act_window">
<field name="name">Products</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">product.template</field>
<field name="view_mode">kanban,tree,form</field>
<field name="view_type">form</field>
<field name="view_id" ref="product.product_template_kanban_view"/>
<field name="context">{"search_default_filter_to_sell":1, "sale_multi_pricelist_product_template": 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new product
</p><p>
You must define a product for everything you purchase,
whether it's a physical product, a consumable or services.
</p>
</field>
</record>
<menuitem id="product_menu_catalog" name="Products" action="product_template_action" parent="sale_menu_root" sequence="4" groups="sales_team.group_sale_salesman"/>
<menuitem id="menu_product" name="Product Variants" parent="product_menu_catalog" sequence="2" groups="base.group_no_one" active="False"/> <menuitem id="menu_product" name="Product Variants" parent="product_menu_catalog" sequence="2" groups="base.group_no_one" active="False"/>
<menuitem action="product.product_template_action" id="menu_product_template_action" parent="product_menu_catalog" sequence="1" active="False"/> <menuitem action="product_template_action" id="menu_product_template_action" parent="product_menu_catalog" sequence="1" active="False"/>
<menuitem id="prod_config_main" name="Products" parent="menu_sale_config" sequence="5"/> <menuitem id="prod_config_main" name="Products" parent="menu_sale_config" sequence="5"/>
<menuitem id="menu_attribute_action" action="product.attribute_action" parent="prod_config_main" sequence="1" groups="base.group_no_one" active="False"/> <menuitem id="menu_attribute_action" action="product.attribute_action" parent="prod_config_main" sequence="1" groups="base.group_no_one" active="False"/>
<menuitem action="product.variants_action" id="menu_variants_action" parent="sale.prod_config_main" sequence="2" groups="base.group_no_one" active="False"/> <menuitem action="product.variants_action" id="menu_variants_action" parent="sale.prod_config_main" sequence="2" groups="base.group_no_one" active="False"/>
+19 -14
View File
@@ -1238,8 +1238,8 @@ class Export(http.Controller):
:rtype: [(str, str)] :rtype: [(str, str)]
""" """
return [ return [
{'tag': 'csv', 'label': 'CSV'},
{'tag': 'xls', 'label': 'Excel', 'error': None if xlwt else "XLWT 1.3.0 required"}, {'tag': 'xls', 'label': 'Excel', 'error': None if xlwt else "XLWT 1.3.0 required"},
{'tag': 'csv', 'label': 'CSV'},
] ]
def fields_get(self, model): def fields_get(self, model):
@@ -1250,24 +1250,29 @@ class Export(http.Controller):
@http.route('/web/export/get_fields', type='json', auth="user") @http.route('/web/export/get_fields', type='json', auth="user")
def get_fields(self, model, prefix='', parent_name= '', def get_fields(self, model, prefix='', parent_name= '',
import_compat=True, parent_field_type=None, import_compat=True, parent_field_type=None,
exclude=None): parent_field=None, exclude=None):
if import_compat and parent_field_type == "many2one": if import_compat and parent_field_type in ['many2one', 'many2many']:
fields = {} fields = self.fields_get(model)
fields = {k: v for k, v in fields.items() if k in ['id', 'name']}
else: else:
fields = self.fields_get(model) fields = self.fields_get(model)
if import_compat: if not import_compat:
fields.pop('id', None)
else:
fields['.id'] = fields.pop('id', {'string': 'ID'}) fields['.id'] = fields.pop('id', {'string': 'ID'})
else:
fields['id']['string'] = _('External ID')
if parent_field:
parent_field['string'] = _('External ID')
fields['id'] = parent_field
fields_sequence = sorted(fields.items(), fields_sequence = sorted(fields.items(),
key=lambda field: odoo.tools.ustr(field[1].get('string', ''))) key=lambda field: (field[0] not in ['id', '.id', 'display_name', 'name'], odoo.tools.ustr(field[1].get('string', ''))))
records = [] records = []
for field_name, field in fields_sequence: for field_name, field in fields_sequence:
if import_compat: if import_compat and not field_name == 'id':
if exclude and field_name in exclude: if exclude and field_name in exclude:
continue continue
if field.get('readonly'): if field.get('readonly'):
@@ -1279,6 +1284,9 @@ class Export(http.Controller):
continue continue
id = prefix + (prefix and '/'or '') + field_name id = prefix + (prefix and '/'or '') + field_name
if field_name == 'name' and import_compat and parent_field_type in ['many2one', 'many2many']:
# Add name field when expand m2o and m2m fields in import-compatible mode
id = prefix
name = parent_name + (parent_name and '/' or '') + field['string'] name = parent_name + (parent_name and '/' or '') + field['string']
record = {'id': id, 'string': name, record = {'id': id, 'string': name,
'value': id, 'children': False, 'value': id, 'children': False,
@@ -1290,11 +1298,8 @@ class Export(http.Controller):
if len(name.split('/')) < 3 and 'relation' in field: if len(name.split('/')) < 3 and 'relation' in field:
ref = field.pop('relation') ref = field.pop('relation')
record['value'] += '/id' record['value'] += '/id'
record['params'] = {'model': ref, 'prefix': id, 'name': name} record['params'] = {'model': ref, 'prefix': id, 'name': name, 'parent_field': field}
record['children'] = True
if not import_compat or field['type'] == 'one2many':
# m2m field in import_compat is childless
record['children'] = True
return records return records
@@ -446,7 +446,10 @@ var ListController = BasicController.extend({
*/ */
_onExportData: function () { _onExportData: function () {
var record = this.model.get(this.handle); var record = this.model.get(this.handle);
new DataExport(this, record).open(); var defaultExportFields = _.map(this.renderer.columns, function (field) {
return field.attrs.name;
});
new DataExport(this, record, defaultExportFields).open();
}, },
/** /**
* Called when the renderer displays an editable row and the user tries to * Called when the renderer displays an editable row and the user tries to
+45 -20
View File
@@ -14,7 +14,7 @@ var _t = core._t;
var DataExport = Dialog.extend({ var DataExport = Dialog.extend({
template: 'ExportDialog', template: 'ExportDialog',
events: { events: {
'click .o_expand_parent': function(e) { 'click .o_expand': function(e) {
this.on_expand_action(this.records[$(e.target).closest('.o_export_tree_item').data('id')]); this.on_expand_action(this.records[$(e.target).closest('.o_export_tree_item').data('id')]);
}, },
'click .o_export_tree_item': function(e) { 'click .o_export_tree_item': function(e) {
@@ -56,10 +56,19 @@ var DataExport = Dialog.extend({
} }
} }
}, },
'dblclick .o_export_tree_item': function(e) { 'dblclick .o_export_tree_item:not(.haschild)': function(e) {
var $elem = $(e.currentTarget); var self = this;
$elem.removeClass('o_selected'); function addElement(el) {
this.add_field($elem.data('id'), $elem.find('.o_tree_column').first().text()); self.add_field(el.getAttribute('data-id'), el.querySelector('.o_tree_column').textContent);
}
var target = e.currentTarget;
target.classList.remove('o_selected');
// Add parent fields to export
[].reverse.call($(target).parents('.o_export_tree_item')).each(function () {
addElement(this);
});
// add field itself
addElement(target);
}, },
'keydown .o_export_tree_item': function(e) { 'keydown .o_export_tree_item': function(e) {
e.stopPropagation(); e.stopPropagation();
@@ -195,7 +204,7 @@ var DataExport = Dialog.extend({
}); });
}, },
}, },
init: function(parent, record) { init: function(parent, record, defaultExportFields) {
var options = { var options = {
title: _t("Export Data"), title: _t("Export Data"),
buttons: [ buttons: [
@@ -206,6 +215,7 @@ var DataExport = Dialog.extend({
this._super(parent, options); this._super(parent, options);
this.records = {}; this.records = {};
this.record = record; this.record = record;
this.defaultExportFields = defaultExportFields;
this.exports = new data.DataSetSearch(this, 'ir.exports', this.record.getContext()); this.exports = new data.DataSetSearch(this, 'ir.exports', this.record.getContext());
this.row_index = 0; this.row_index = 0;
@@ -228,13 +238,14 @@ var DataExport = Dialog.extend({
var got_fields = new $.Deferred(); var got_fields = new $.Deferred();
this.$import_compat_radios.change(function(e) { this.$import_compat_radios.change(function(e) {
self.isCompatibleMode = !!$(e.target).val();
self.$('.o_field_tree_structure').remove(); self.$('.o_field_tree_structure').remove();
self._rpc({ self._rpc({
route: '/web/export/get_fields', route: '/web/export/get_fields',
params: { params: {
model: self.record.model, model: self.record.model,
import_compat: !!$(e.target).val(), import_compat: self.isCompatibleMode,
}, },
}) })
.done(function (records) { .done(function (records) {
@@ -250,6 +261,15 @@ var DataExport = Dialog.extend({
.remove(); .remove();
got_fields.resolve(); got_fields.resolve();
self.on_show_data(records); self.on_show_data(records);
// In compatible mode add ID field as first field to export
if (self.isCompatibleMode) {
self.$('.o_fields_list').prepend(new Option(_('External ID'), 'id'));
}
_.each(records, function (record) {
if (_.contains(self.defaultExportFields, record.id)) {
self.add_field(record.id, record.string);
}
});
}); });
}).eq(0).change(); }).eq(0).change();
waitFor.push(got_fields); waitFor.push(got_fields);
@@ -262,10 +282,15 @@ var DataExport = Dialog.extend({
self.ids_to_export = false; self.ids_to_export = false;
self.domain = domain; self.domain = domain;
} }
self.on_show_domain();
})); }));
waitFor.push(this.show_exports_list()); waitFor.push(this.show_exports_list().then(function () {
_.each(self.records, function (record, key) {
if (_.contains(self.defaultExportFields, key)) {
self.add_field(key, record.string);
}
});
}));
return $.when.apply($, waitFor); return $.when.apply($, waitFor);
@@ -274,14 +299,15 @@ var DataExport = Dialog.extend({
_.each(formats, function(format, i) { _.each(formats, function(format, i) {
var $radio = $('<input/>', {type: 'radio', value: format.tag, name: 'o_export_format_name'}); var $radio = $('<input/>', {type: 'radio', value: format.tag, name: 'o_export_format_name'});
var $label = $('<label/>', {html: format.label}); var $label = $('<span/>', {html: format.label});
if (format.error) { if (format.error) {
$radio.prop('disabled', true); $radio.prop('disabled', true);
$label.html(_.str.sprintf("%s — %s", format.label, format.error)); $label.html(_.str.sprintf("%s — %s", format.label, format.error));
} }
$fmts.append($("<div/>").append($radio, $label)); var $radioButton = $('<label/>').append($radio, $label);
$fmts.append($("<div class='radio'></div>").append($radioButton));
}); });
self.$export_format_inputs = $fmts.find('input'); self.$export_format_inputs = $fmts.find('input');
@@ -366,7 +392,8 @@ var DataExport = Dialog.extend({
parent_name: name, parent_name: name,
import_compat: !!this.$import_compat_radios.filter(':checked').val(), import_compat: !!this.$import_compat_radios.filter(':checked').val(),
parent_field_type : record['field_type'], parent_field_type : record['field_type'],
exclude: exclude_fields parent_field: record['params']['parent_field'],
exclude: exclude_fields,
}, },
}) })
.done(function(results) { .done(function(results) {
@@ -377,17 +404,13 @@ var DataExport = Dialog.extend({
this.show_content(record.id); this.show_content(record.id);
} }
}, },
on_show_domain: function() {
this.$('p').first()
.after(QWeb.render('Export.DomainMessage', {record: this}));
},
on_show_data: function(records, expansion) { on_show_data: function(records, expansion) {
var self = this; var self = this;
if(expansion) { if(expansion) {
this.$('.o_export_tree_item[data-id="' + expansion + '"]') this.$('.o_export_tree_item[data-id="' + expansion + '"]')
.addClass('open') .addClass('open')
.find('.o_expand_parent') .find('.o_expand_parent')
.toggleClass('fa-plus fa-minus') .toggleClass('fa-chevron-right fa-chevron-down')
.next() .next()
.after(QWeb.render('Export.TreeItems', {'fields': records, 'debug': this.getSession().debug})); .after(QWeb.render('Export.TreeItems', {'fields': records, 'debug': this.getSession().debug}));
} else { } else {
@@ -409,7 +432,7 @@ var DataExport = Dialog.extend({
$this.toggleClass('open'); $this.toggleClass('open');
var is_open = $this.hasClass('open'); var is_open = $this.hasClass('open');
$this.children('.o_expand_parent').toggleClass('fa-minus', !!is_open).toggleClass('fa-plus', !is_open); $this.children('.o_expand_parent').toggleClass('fa-chevron-down', !!is_open).toggleClass('fa-chevron-right', !is_open);
var $child_field = $this.find('.o_export_tree_item'); var $child_field = $this.find('.o_export_tree_item');
var child_len = (id.split("/")).length + 1; var child_len = (id.split("/")).length + 1;
@@ -420,7 +443,7 @@ var DataExport = Dialog.extend({
} else if(child_len === $child_field.eq(i).data('id').split("/").length) { } else if(child_len === $child_field.eq(i).data('id').split("/").length) {
if ($child.hasClass('open')) { if ($child.hasClass('open')) {
$child.removeClass('open'); $child.removeClass('open');
$child.children('.o_expand_parent').removeClass('fa-minus').addClass('fa-plus'); $child.children('.o_expand_parent').removeClass('fa-chevron-down').addClass('fa-chevron-right');
} }
$child.show(); $child.show();
} }
@@ -455,7 +478,9 @@ var DataExport = Dialog.extend({
Dialog.alert(this, _t("Please select fields to export...")); Dialog.alert(this, _t("Please select fields to export..."));
return; return;
} }
exported_fields.unshift({name: 'id', label: _t('External ID')}); if (!this.isCompatibleMode) {
exported_fields.unshift({name: 'id', label: _t('External ID')});
}
var export_format = this.$export_format_inputs.filter(':checked').val(); var export_format = this.$export_format_inputs.filter(':checked').val();
+1 -19
View File
@@ -8,25 +8,6 @@
@include o-flex(0, 0, auto); @include o-flex(0, 0, auto);
} }
label {
vertical-align: top;
font-weight: normal;
}
.o_import_compat, .o_export_format {
display: inline-block;
> div {
margin-left: 5px;
@include o-inline-flex-display;
@include o-align-items(center);
> label, > input {
margin: 0 0 0 5px;
}
}
}
.o_export_panel { .o_export_panel {
@include o-flex(1, 1, auto); @include o-flex(1, 1, auto);
position: relative; position: relative;
@@ -77,6 +58,7 @@
.o_expand_parent { .o_expand_parent {
@include o-position-absolute($top: 4px, $left: 5px); @include o-position-absolute($top: 4px, $left: 5px);
font-size: 10px;
} }
.o_tree_column { .o_tree_column {
display: block; display: block;
+28 -22
View File
@@ -1281,21 +1281,26 @@
</div> </div>
<div t-name="ExportDialog" class="o_export"> <div t-name="ExportDialog" class="o_export">
<p role="status">
This wizard will export all data that matches the current search criteria to a CSV file.
You can export all data or only the fields that can be reimported after modification.
</p>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<label>Export Type :</label> <strong>What do you want to do?</strong>
<div class="o_import_compat"> <div class="o_import_compat">
<div><input type="radio" name="o_import_compat_name" value="yes" checked="checked"/><label>Import-Compatible Export</label></div> <div class="radio">
<div><input type="radio" name="o_import_compat_name" value=""/><label>Export all Data</label></div> <label>
<input type="radio" name="o_import_compat_name" value="" checked="checked"/>
Use data in a spreadsheet (export all data)
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="o_import_compat_name" value="yes"/>
Update data (import-compatible export)
</label>
</div>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label>Export Formats :</label> <strong>Export Format:</strong>
<div class="o_export_format"/> <div class="o_export_format"/>
</div> </div>
</div> </div>
@@ -1323,19 +1328,20 @@
</div> </div>
</div> </div>
</div> </div>
<p t-name="Export.DomainMessage"> <t t-name="Export.TreeItems">
<strong t-if="!record.ids_to_export" role="alert">Please pay attention that all records matching your search filter will be exported. Not only the selected ids.</strong> <t t-foreach="fields" t-as="field">
<strong t-if="record.ids_to_export" role="alert">Please note that only the selected ids will be exported.</strong> <t t-set="has_child" t-value="field.children &amp;&amp; (field.id).split('/').length != 3" />
</p> <div t-att-data-id="field.id" t-attf-class="o_export_tree_item #{has_child ? 'haschild' : ''}" tabindex="-1" role="treeitem"> <!-- tabindex make the div focusable -->
<div t-name="Export.TreeItems" <t t-if="has_child">
t-foreach="fields" t-as="field" <span class="o_expand o_expand_parent fa fa-chevron-right" role="img" aria-label="Expand parents" title="Expand parents"/>
t-att-data-id="field.id" <span class="o_tree_column o_expand" t-att-title="debug and field.id or None"><t t-esc="field.string"/></span>
class="o_export_tree_item" </t>
tabindex="-1" role="treeitem"> <!-- tabindex make the div focusable --> <t t-else="">
<span t-if="field.children &amp;&amp; (field.id).split('/').length != 3" <span class="o_tree_column" t-att-title="debug and field.id or None"><t t-esc="field.string"/></span>
class="o_expand_parent fa fa-plus" role="img" aria-label="Expand parents" title="Expand parents"/> </t>
<span class="o_tree_column" t-att-title="debug and field.id or None"><t t-esc="field.string"/></span> </div>
</div> </t>
</t>
<t t-name="Export.SaveList"> <t t-name="Export.SaveList">
<label>Save as:</label> <input type="text"/><button type="button" class="btn btn-sm btn-default">Ok</button> <label>Save as:</label> <input type="text"/><button type="button" class="btn btn-sm btn-default">Ok</button>
</t> </t>
+7
View File
@@ -829,6 +829,13 @@ class Partner(models.Model):
'company_name', 'state_id.code', 'state_id.name', 'company_name', 'state_id.code', 'state_id.name',
] ]
@api.model
def get_import_templates(self):
return [{
'label': _('Import Template for Customers'),
'template': '/base/static/xls/res_partner.xls'
}]
class ResPartnerIndustry(models.Model): class ResPartnerIndustry(models.Model):
_description = 'Industry' _description = 'Industry'
Binary file not shown.
+1
View File
@@ -1,4 +1,5 @@
Babel==2.3.4 Babel==2.3.4
chardet==3.0.4
decorator==4.0.10 decorator==4.0.10
docutils==0.12 docutils==0.12
ebaysdk==2.1.5 ebaysdk==2.1.5