[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 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import chardet
import datetime
import io
import itertools
@@ -45,6 +46,37 @@ EXTENSIONS = {
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):
@@ -251,15 +283,10 @@ class Import(models.TransientModel):
def _read_csv(self, options):
""" 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 UnicodeDecodeError: if ``options.encoding`` is incorrect
"""
csv_data = self.file
# TODO: guess encoding with chardet? Or https://github.com/aadsm/jschardet
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')
encoding = chardet.detect(csv_data)['encoding']
csv_data = csv_data.decode(encoding).encode('utf-8')
csv_iterator = pycompat.csv_reader(
io.BytesIO(csv_data),
@@ -376,7 +403,7 @@ class Import(models.TransientModel):
if 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
def _find_type_from_preview(self, options, preview):
@@ -400,6 +427,7 @@ class Import(models.TransientModel):
:rtype: list(Field)
"""
string_match = None
IrTranslation = self.env['ir.translation']
for field in fields:
# FIXME: should match all translations & original
# TODO: use string distance (levenshtein? hamming?)
@@ -409,6 +437,9 @@ class Import(models.TransientModel):
# matching string are not reliable way because
# strings have no unique constraint
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:
# this behavior is only applied if there is no matching field['name']
return [string_match]
@@ -440,23 +471,31 @@ class Import(models.TransientModel):
Will consume the first line of the ``rows`` iterator.
Returns a pair of (None, None) if headers were not requested
or the list of headers and a dict mapping cell indices
to key paths in the ``fields`` tree
Returns the list of headers and a dict mapping cell indices
to key paths in the ``fields`` tree. If headers were not
requested, both collections are empty.
:param Iterator rows:
:param dict fields:
:param dict options:
:rtype: (None, None) | (list(str), dict(int: list(str)))
:rtype: (list(str), dict(int: list(str)))
"""
if not options.get('headers'):
return [], {}
headers = next(rows)
return headers, {
index: [field['name'] for field in self._match_header(header, fields, options)] or None
for index, header in enumerate(headers)
}
matches = {}
mapping_records = self.env['base_import.mapping'].search_read([('res_model', '=', self.res_model)], ['column_name', 'field_name'])
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
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 options: format-specific options.
CSV: {encoding, quoting, separator, headers}
CSV: {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}
@@ -482,14 +521,23 @@ class Import(models.TransientModel):
# Match should have consumed the first row (iif headers), get
# the ``count`` next rows for preview
preview = list(itertools.islice(rows, count))
assert preview, "CSV file seems to have no content"
assert preview, "file seems to have no content"
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 = {}
for index, match in enumerate(options.get('fields')):
if match:
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 {
'fields': fields,
'matches': matches or False,
@@ -497,6 +545,7 @@ class Import(models.TransientModel):
'headers_type': header_types or False,
'preview': preview,
'options': options,
'advanced_mode': advanced_mode,
'debug': self.user_has_groups('base.group_no_one'),
}
except Exception as error:
@@ -505,7 +554,7 @@ class Import(models.TransientModel):
# preview to a list in the return.
_logger.debug("Error during parsing preview", exc_info=True)
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')
return {
'error': str(error),
@@ -627,7 +676,13 @@ class Import(models.TransientModel):
try:
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))
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:
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 /)
@@ -643,12 +698,14 @@ class Import(models.TransientModel):
return data
@api.multi
def do(self, fields, options, dryrun=False):
def do(self, fields, columns, options, dryrun=False):
""" 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 columns: columns label
:type columns: list(str|bool)
:param dict options:
:param bool dryrun: performs all import operations (and
validations) but rollbacks writes, allows
@@ -661,7 +718,7 @@ class Import(models.TransientModel):
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})
:rtype: dict(ids: list(int), messages: list({type, message, record}))
"""
self.ensure_one()
self._cr.execute('SAVEPOINT import')
@@ -671,11 +728,13 @@ class Import(models.TransientModel):
# Parse date and float field
data = self._parse_import_data(data, import_fields, options)
except ValueError as error:
return [{
'type': 'error',
'message': pycompat.text_type(error),
'record': False,
}]
return {
'messages': [{
'type': 'error',
'message': pycompat.text_type(error),
'record': False,
}]
}
_logger.info('importing %d rows...', len(data))
@@ -698,4 +757,20 @@ class Import(models.TransientModel):
except psycopg2.InternalError:
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_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_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, {
template: 'ImportView',
opts: [
{name: 'encoding', label: _lt("Encoding:"), value: 'utf-8'},
{name: 'separator', label: _lt("Separator:"), value: ','},
{name: 'quoting', label: _lt("Text Delimiter:"), value: '"'}
],
@@ -60,7 +59,6 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
events: {
// 'change .oe_import_grid input': 'import_dryrun',
'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_advanced_mode': function (e) {
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
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 () {
var self = this;
this.setup_encoding_picker();
this.setup_separator_picker();
this.setup_float_format_picker();
@@ -124,7 +134,6 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
self.$('input[name=import_id]').val(id);
self.renderButtons();
self.renderImportLink();
var status = {
cp_content: {$buttons: self.$buttons},
};
@@ -145,36 +154,15 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
this.$buttons = $(QWeb.render("ImportView.buttons", 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_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) {
e.preventDefault();
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 () {
this.$('input.oe_import_separator').select2({
width: '160px',
@@ -196,26 +184,33 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
});
},
setup_float_format_picker: function () {
var sug_query = function (q) {
var suggestions = [
{id: ',', text: _t("Comma")},
{id: '.', text: _t("Dot")},
];
if (q.term) {
suggestions.unshift({id: q.term, text: q.term});
}
q.callback({results: suggestions});
};
var sub_query = function (q, options) {
var suggestions = [
{id: ',', text: _t("Comma")},
{id: '.', text: _t("Dot")},
];
if (options && options.no_separator) {
suggestions.push({id: '', text: _t("No Separator")});
}
if (q.term) {
suggestions.unshift({id: q.term, text: q.term});
}
q.callback({results: suggestions});
};
this.$('input.oe_import_float_thousand_separator').select2({
width: '160px',
query: sug_query,
query: function (q) {
sub_query(q, {no_separator: true})
},
initSelection: function (e, c) {
return c({id: ',', text: _t("Comma")});
},
});
this.$('input.oe_import_float_decimal_separator').select2({
width: '160px',
query: sug_query,
query: function (q) {
sub_query(q);
},
initSelection: function (e, c) {
return c({id: ',', text: _t("Dot")});
},
@@ -254,9 +249,7 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
//- File & settings change section
onfile_loaded: function () {
var file = this.$('.oe_import_file')[0].files[0];
this.$('.oe_import_file_show').val(file !== undefined && file.name || '');
this.$buttons.filter('.o_import_button').add(this.$('.oe_import_file_reload'))
.prop('disabled', true);
this.$buttons.filter('.o_import_import, .o_import_validate, .o_import_file_reload').addClass('hidden');
if (!this.$('input.oe_import_file').val()) { return this['settings_changed'](); }
this.$('.oe_import_date_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")) {
import_toggle = true;
}
this.$el.find('.oe_import_toggle').toggle(import_toggle);
this.$el.find('.oe_import_box').toggle(import_toggle);
jsonp(this.$el, {
url: '/base_import/set_file'
}, this.proxy('settings_changed'));
},
onpreviewing: function () {
var self = this;
this.$buttons.filter('.o_import_button').add(this.$('.oe_import_file_reload'))
.prop('disabled', true);
this.$buttons.filter('.o_import_import, .o_import_validate, .o_import_file_reload').addClass('hidden');
this.$el.addClass('oe_import_with_file');
// TODO: test that write // succeeded?
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) {
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.find('.oe_import_box, .oe_import_with_file').removeClass('hidden');
this.$el.find('.o_view_nocontent').addClass('hidden');
this.$('.oe_import_error_report').html(
QWeb.render('ImportView.preview.error', result));
},
onpreview_success: function (event, from, to, result) {
var self = this;
this.$buttons.filter('.o_import_import').removeClass('btn-primary');
this.$buttons.filter('.o_import_validate').addClass('btn-primary');
this.$buttons.filter('.o_import_button').add(this.$('.oe_import_file_reload'))
.prop('disabled', false);
this.$buttons.filter('.oe_import_file')
.text(_t('Load New File'))
.removeClass('btn-primary').addClass('btn-default')
.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.$('input.oe_import_advanced_mode').prop('checked', result.advanced_mode);
this.$('.oe_import_grid').html(QWeb.render('ImportView.preview', result));
if (result.headers.length === 1) {
this.$('.oe_import_options').show();
this.onresults(null, null, null, [{
this.onresults(null, null, null, {'messages': [{
type: 'warning',
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));
@@ -461,6 +459,9 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
var fields = this.$('.oe_import_fields input.oe_import_match_field').map(function (index, el) {
return $(el).select2('val') || false;
}).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 defer_parent_store = 'defer_parent_store' in kwargs ? kwargs.defer_parent_store : !!this.$('#oe_import_deferparentstore').prop('checked')
delete kwargs.tracking_disable;
@@ -472,18 +473,18 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
return this._rpc({
model: 'base_import.import',
method: 'do',
args: [this.id, fields, this.import_options()],
args: [this.id, fields, columns, this.import_options()],
kwargs : kwargs,
}).fail(function (error, event) {
// In case of unexpected exception, convert
// "JSON-RPC error" to an import failure, and
// prevent default handling (warning dialog)
if (event) { event.preventDefault(); }
return $.when([{
return $.when({'messages': [{
type: 'error',
record: false,
message: error.data.arguments && error.data.arguments[1] || error.message,
}]);
}]});
}) ;
},
onvalidate: function () {
@@ -492,27 +493,26 @@ var DataImport = AbstractAction.extend(ControlPanelMixin, {
},
onimport: function () {
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) {
return message.type === 'error'; })) {
self['import_succeeded']();
self['import_succeeded'](results);
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();
},
exit: function () {
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);
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) {
message.push({
type: 'info',
@@ -591,7 +591,7 @@ StateMachine.create({
{ name: 'import', from: ['preview_success', 'results'], to: 'importing' },
{ name: 'import_succeeded', from: 'importing', to: 'imported'},
{ name: 'import_failed', from: 'importing', to: 'results' }
]
],
});
return {
+7 -5
View File
@@ -1,6 +1,5 @@
.oe_import {
@include o-webclient-padding($top: 8px);
background-color: white;
overflow: auto;
position: absolute; // Needed for chrome
top: 0;
@@ -27,7 +26,7 @@
}
.oe_import_box {
padding: 16px;
padding: 8px;
background: #F0EEEE;
border-radius: $border-radius-base;
border: solid 1px #dddddd;
@@ -39,9 +38,6 @@
}
}
.oe_import_toggle {
margin-top: 8px;
}
a.oe_import_toggle {
display: block;
&:before {
@@ -94,6 +90,12 @@
margin: 2px 0;
list-style: none;
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"
t-att-value="widget.session.session_id"/>
<input type="hidden" name="import_id"/>
<div class="oe_import_box col-sm-9">
<div class="col-sm-12">
<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>
</div>
<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">
<div class="oe_import_box col-md-12 hidden">
<input accept=".csv, .xls, .xlsx, .xlsm, .ods" t-attf-id="file_#{_id}"
name="file" class="oe_import_file" type="file" style="display:none;"/>
<div class="oe_import_with_file">
<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">
<p t-foreach="widget.opts" t-as="option">
<!-- no @name, avoid submission when file_update called -->
@@ -57,7 +35,7 @@
</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>
<div title="If the model uses openchatter, history tracking
will set up subscriptions and send notifications
@@ -90,16 +68,35 @@
simpler especially when the file has many columns.</p>
<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>
</form>
</t>
<t t-name="ImportView.buttons">
<button type="button" class="btn btn-sm btn-primary o_import_button o_import_validate"
disabled="disabled">Test Import</button>
<button type="button" class="btn btn-sm btn-default o_import_button o_import_import"
disabled="disabled">Import</button>
<button type="button" class="btn btn-sm btn-primary o_import_import hidden">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-primary oe_import_file">Load File</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>
</t>
@@ -107,7 +104,7 @@
<thead>
<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>
><span class="o_import_header_name"><t t-esc="header"/></span></td>
</tr>
<tr class="oe_import_fields">
<!-- Iterate on first row to ensure we have all columns -->
@@ -126,7 +123,7 @@
<t t-name="ImportView.preview.error">
<div class="oe_import_report bg-danger text-danger">
<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>
</div>
<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):
def make_import(self):
@@ -207,7 +232,7 @@ class TestPreview(TransactionCase):
'quoting': '"',
'separator': ',',
})
self.assertTrue('error' in result)
self.assertFalse('error' in result)
@mute_logger('odoo.addons.base_import.models.base_import')
def test_csv_errors(self):
@@ -216,14 +241,12 @@ class TestPreview(TransactionCase):
result = import_wizard.parse_preview({
'quoting': 'foo',
'separator': ',',
'encoding': 'euc_kr',
})
self.assertTrue('error' in result)
result = import_wizard.parse_preview({
'quoting': '"',
'separator': 'bob',
'encoding': 'euc_kr',
})
self.assertTrue('error' in result)
@@ -258,7 +281,7 @@ class TestPreview(TransactionCase):
['qux', '5', '6'],
])
# 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")
def test_xls_success(self):
@@ -288,7 +311,7 @@ class TestPreview(TransactionCase):
['qux', '5', '6'],
])
# 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")
def test_xlsx_success(self):
@@ -318,7 +341,7 @@ class TestPreview(TransactionCase):
['qux', '5', '6'],
])
# 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")
def test_ods_success(self):
@@ -348,7 +371,7 @@ class TestPreview(TransactionCase):
['aux', '5', '6'],
])
# 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):
@@ -388,6 +411,7 @@ class test_convert_import_data(TransactionCase):
results = import_wizard.do(
['name', 'date', 'create_date'],
[],
{
'date_format': '%Y年%m月%d日',
'datetime_format': '%Y-%m-%d %H:%M',
@@ -398,7 +422,7 @@ class test_convert_import_data(TransactionCase):
)
# if results empty, no errors
self.assertItemsEqual(results, [])
self.assertItemsEqual(results['messages'], [])
def test_parse_relational_fields(self):
""" Ensure that relational fields float and date are correctly
@@ -570,5 +594,6 @@ class test_failures(TransactionCase):
})
results = import_wizard.do(
['name', 'db_datas'],
[],
{'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
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):
Binary file not shown.
+7
View File
@@ -273,6 +273,13 @@ class Employee(models.Model):
except AccessError:
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):
_name = "hr.department"
Binary file not shown.
+7
View File
@@ -175,6 +175,13 @@ class MrpBom(models.Model):
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):
_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_pricelist_views.xml',
'views/res_partner_views.xml',
'views/product_template_import_templates.xml',
'views/res_users_views.xml',
'report/product_reports.xml',
'report/product_pricelist_templates.xml',
+7
View File
@@ -630,3 +630,10 @@ class SupplierInfo(models.Model):
delay = fields.Integer(
'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.")
@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
@api.model
def get_import_templates(self):
return [{
'label': _('Import Template for Pricelists'),
'template': '/product/static/xls/product_pricelist.xls'
}]
class ResCountryGroup(models.Model):
_inherit = 'res.country.group'
@@ -470,4 +477,3 @@ class PricelistItem(models.Model):
'price_min_margin': 0.0,
'price_max_margin': 0.0,
})
@@ -483,3 +483,10 @@ class ProductTemplate(models.Model):
empty_list_help_document_name=_("product"),
)
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 -*-
# 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
@@ -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_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):
_name = 'product.product'
Binary file not shown.
+1 -1
View File
@@ -55,7 +55,7 @@
<field name="res_model">product.template</field>
<field name="view_type">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="view_id" eval="False"/> <!-- Force empty -->
<field name="help" type="html">
+16 -1
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# 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
@@ -59,3 +59,18 @@ class ProductTemplate(models.Model):
if self.type == 'consu':
self.invoice_policy = 'order'
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"
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 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="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"/>
+19 -14
View File
@@ -1238,8 +1238,8 @@ class Export(http.Controller):
:rtype: [(str, str)]
"""
return [
{'tag': 'csv', 'label': 'CSV'},
{'tag': 'xls', 'label': 'Excel', 'error': None if xlwt else "XLWT 1.3.0 required"},
{'tag': 'csv', 'label': 'CSV'},
]
def fields_get(self, model):
@@ -1250,24 +1250,29 @@ class Export(http.Controller):
@http.route('/web/export/get_fields', type='json', auth="user")
def get_fields(self, model, prefix='', parent_name= '',
import_compat=True, parent_field_type=None,
exclude=None):
parent_field=None, exclude=None):
if import_compat and parent_field_type == "many2one":
fields = {}
if import_compat and parent_field_type in ['many2one', 'many2many']:
fields = self.fields_get(model)
fields = {k: v for k, v in fields.items() if k in ['id', 'name']}
else:
fields = self.fields_get(model)
if import_compat:
fields.pop('id', None)
else:
if not import_compat:
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(),
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 = []
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:
continue
if field.get('readonly'):
@@ -1279,6 +1284,9 @@ class Export(http.Controller):
continue
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']
record = {'id': id, 'string': name,
'value': id, 'children': False,
@@ -1290,11 +1298,8 @@ class Export(http.Controller):
if len(name.split('/')) < 3 and 'relation' in field:
ref = field.pop('relation')
record['value'] += '/id'
record['params'] = {'model': ref, 'prefix': id, 'name': name}
if not import_compat or field['type'] == 'one2many':
# m2m field in import_compat is childless
record['children'] = True
record['params'] = {'model': ref, 'prefix': id, 'name': name, 'parent_field': field}
record['children'] = True
return records
@@ -446,7 +446,10 @@ var ListController = BasicController.extend({
*/
_onExportData: function () {
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
+45 -20
View File
@@ -14,7 +14,7 @@ var _t = core._t;
var DataExport = Dialog.extend({
template: 'ExportDialog',
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')]);
},
'click .o_export_tree_item': function(e) {
@@ -56,10 +56,19 @@ var DataExport = Dialog.extend({
}
}
},
'dblclick .o_export_tree_item': function(e) {
var $elem = $(e.currentTarget);
$elem.removeClass('o_selected');
this.add_field($elem.data('id'), $elem.find('.o_tree_column').first().text());
'dblclick .o_export_tree_item:not(.haschild)': function(e) {
var self = this;
function addElement(el) {
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) {
e.stopPropagation();
@@ -195,7 +204,7 @@ var DataExport = Dialog.extend({
});
},
},
init: function(parent, record) {
init: function(parent, record, defaultExportFields) {
var options = {
title: _t("Export Data"),
buttons: [
@@ -206,6 +215,7 @@ var DataExport = Dialog.extend({
this._super(parent, options);
this.records = {};
this.record = record;
this.defaultExportFields = defaultExportFields;
this.exports = new data.DataSetSearch(this, 'ir.exports', this.record.getContext());
this.row_index = 0;
@@ -228,13 +238,14 @@ var DataExport = Dialog.extend({
var got_fields = new $.Deferred();
this.$import_compat_radios.change(function(e) {
self.isCompatibleMode = !!$(e.target).val();
self.$('.o_field_tree_structure').remove();
self._rpc({
route: '/web/export/get_fields',
params: {
model: self.record.model,
import_compat: !!$(e.target).val(),
import_compat: self.isCompatibleMode,
},
})
.done(function (records) {
@@ -250,6 +261,15 @@ var DataExport = Dialog.extend({
.remove();
got_fields.resolve();
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();
waitFor.push(got_fields);
@@ -262,10 +282,15 @@ var DataExport = Dialog.extend({
self.ids_to_export = false;
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);
@@ -274,14 +299,15 @@ var DataExport = Dialog.extend({
_.each(formats, function(format, i) {
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) {
$radio.prop('disabled', true);
$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');
@@ -366,7 +392,8 @@ var DataExport = Dialog.extend({
parent_name: name,
import_compat: !!this.$import_compat_radios.filter(':checked').val(),
parent_field_type : record['field_type'],
exclude: exclude_fields
parent_field: record['params']['parent_field'],
exclude: exclude_fields,
},
})
.done(function(results) {
@@ -377,17 +404,13 @@ var DataExport = Dialog.extend({
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) {
var self = this;
if(expansion) {
this.$('.o_export_tree_item[data-id="' + expansion + '"]')
.addClass('open')
.find('.o_expand_parent')
.toggleClass('fa-plus fa-minus')
.toggleClass('fa-chevron-right fa-chevron-down')
.next()
.after(QWeb.render('Export.TreeItems', {'fields': records, 'debug': this.getSession().debug}));
} else {
@@ -409,7 +432,7 @@ var DataExport = Dialog.extend({
$this.toggleClass('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_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) {
if ($child.hasClass('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();
}
@@ -455,7 +478,9 @@ var DataExport = Dialog.extend({
Dialog.alert(this, _t("Please select fields to export..."));
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();
+1 -19
View File
@@ -8,25 +8,6 @@
@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 {
@include o-flex(1, 1, auto);
position: relative;
@@ -77,6 +58,7 @@
.o_expand_parent {
@include o-position-absolute($top: 4px, $left: 5px);
font-size: 10px;
}
.o_tree_column {
display: block;
+28 -22
View File
@@ -1281,21 +1281,26 @@
</div>
<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="col-md-6">
<label>Export Type :</label>
<strong>What do you want to do?</strong>
<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><input type="radio" name="o_import_compat_name" value=""/><label>Export all Data</label></div>
<div class="radio">
<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 class="col-md-6">
<label>Export Formats :</label>
<strong>Export Format:</strong>
<div class="o_export_format"/>
</div>
</div>
@@ -1323,19 +1328,20 @@
</div>
</div>
</div>
<p t-name="Export.DomainMessage">
<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>
<strong t-if="record.ids_to_export" role="alert">Please note that only the selected ids will be exported.</strong>
</p>
<div t-name="Export.TreeItems"
t-foreach="fields" t-as="field"
t-att-data-id="field.id"
class="o_export_tree_item"
tabindex="-1" role="treeitem"> <!-- tabindex make the div focusable -->
<span t-if="field.children &amp;&amp; (field.id).split('/').length != 3"
class="o_expand_parent fa fa-plus" role="img" aria-label="Expand parents" title="Expand parents"/>
<span class="o_tree_column" t-att-title="debug and field.id or None"><t t-esc="field.string"/></span>
</div>
<t t-name="Export.TreeItems">
<t t-foreach="fields" t-as="field">
<t t-set="has_child" t-value="field.children &amp;&amp; (field.id).split('/').length != 3" />
<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 -->
<t t-if="has_child">
<span class="o_expand o_expand_parent fa fa-chevron-right" role="img" aria-label="Expand parents" title="Expand parents"/>
<span class="o_tree_column o_expand" t-att-title="debug and field.id or None"><t t-esc="field.string"/></span>
</t>
<t t-else="">
<span class="o_tree_column" t-att-title="debug and field.id or None"><t t-esc="field.string"/></span>
</t>
</div>
</t>
</t>
<t t-name="Export.SaveList">
<label>Save as:</label> <input type="text"/><button type="button" class="btn btn-sm btn-default">Ok</button>
</t>
+7
View File
@@ -829,6 +829,13 @@ class Partner(models.Model):
'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):
_description = 'Industry'
Binary file not shown.
+1
View File
@@ -1,4 +1,5 @@
Babel==2.3.4
chardet==3.0.4
decorator==4.0.10
docutils==0.12
ebaysdk==2.1.5