From 46df9da439af70e286b346fc5d37fdb49a947b64 Mon Sep 17 00:00:00 2001 From: Hitesh Trivedi Date: Wed, 20 Apr 2016 10:01:21 +0200 Subject: [PATCH] [MIG] base_import : migrate to new api --- addons/base_import/controllers/main.py | 20 +- addons/base_import/models/base_import.py | 313 ++++++++++--------- addons/base_import/tests/test_base_import.py | 2 +- 3 files changed, 177 insertions(+), 158 deletions(-) diff --git a/addons/base_import/controllers/main.py b/addons/base_import/controllers/main.py index 84b645f149f..23febc086e0 100644 --- a/addons/base_import/controllers/main.py +++ b/addons/base_import/controllers/main.py @@ -1,19 +1,23 @@ # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + import cgi import json -from openerp.http import Controller, route +from odoo import http +from odoo.http import request -class ImportController(Controller): - @route('/base_import/set_file', methods=['POST']) - def set_file(self, req, file, import_id, jsonp='callback'): + +class ImportController(http.Controller): + + @http.route('/base_import/set_file', methods=['POST']) + def set_file(self, file, import_id, jsonp='callback'): import_id = int(import_id) - written = req.session.model('base_import.import').write(import_id, { + written = request.env['base_import.import'].browse(import_id).write({ 'file': file.read(), 'file_name': file.filename, 'file_type': file.content_type, - }, req.context) + }) - return 'window.top.%s(%s)' % ( - cgi.escape(jsonp), json.dumps({'result': written})) + return 'window.top.%s(%s)' % (cgi.escape(jsonp), json.dumps({'result': written})) diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py index 36cd41fa7f0..3f872edfc26 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -1,25 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + import csv import datetime import io import itertools import logging +import psycopg2 import operator import os import re -from openerp.tools.mimetypes import guess_mimetype +from odoo import api, fields, models +from odoo.tools.translate import _ +from odoo.tools.mimetypes import guess_mimetype +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT try: from cStringIO import StringIO except ImportError: from StringIO import StringIO -import psycopg2 - -from openerp.osv import orm, fields -from openerp.tools.translate import _ -from openerp.tools import DEFAULT_SERVER_DATE_FORMAT, \ - DEFAULT_SERVER_DATETIME_FORMAT FIELDS_RECURSION_LIMIT = 2 ERROR_PREVIEW_BYTES = 200 @@ -50,21 +51,21 @@ EXTENSIONS = { for mime, (ext, handler, req) in FILE_TYPE_DICT.iteritems() } -class ir_import(orm.TransientModel): + +class Import(models.TransientModel): + _name = 'base_import.import' + # allow imports to survive for 12h in case user is slow _transient_max_hours = 12.0 - _columns = { - 'res_model': fields.char('Model'), - 'file': fields.binary( - 'File', help="File to check and/or import, raw binary (not base64)"), - 'file_name': fields.char('File Name'), - 'file_type': fields.char(string='File Type'), - } + res_model = fields.Char('Model') + file = fields.Binary('File', help="File to check and/or import, raw binary (not base64)") + file_name = fields.Char('File Name') + file_type = fields.Char('File Type') - def get_fields(self, cr, uid, model, context=None, - depth=FIELDS_RECURSION_LIMIT): + @api.model + def get_fields(self, model, depth=FIELDS_RECURSION_LIMIT): """ Recursively get fields for the provided model (through fields_get) and filter them according to importability @@ -108,8 +109,8 @@ class ir_import(orm.TransientModel): :param str model: name of the model to get fields form :param int landing: depth of recursion into o2m fields """ - model_obj = self.pool[model] - fields = [{ + Model = self.env[model] + importable_fields = [{ 'id': 'id', 'name': 'id', 'string': _("External ID"), @@ -117,9 +118,9 @@ class ir_import(orm.TransientModel): 'fields': [], 'type': 'id', }] - fields_got = model_obj.fields_get(cr, uid, context=context) - blacklist = orm.MAGIC_COLUMNS + [model_obj.CONCURRENCY_CHECK_FIELD] - for name, field in fields_got.iteritems(): + model_fields = Model.fields_get() + blacklist = models.MAGIC_COLUMNS + [Model.CONCURRENCY_CHECK_FIELD] + for name, field in model_fields.iteritems(): if name in blacklist: continue # an empty string means the field is deprecated, @deprecated must @@ -132,10 +133,9 @@ class ir_import(orm.TransientModel): continue # states = {state: [(attr, value), (attr2, value2)], state2:...} if not any(attr == 'readonly' and value is False - for attr, value in itertools.chain.from_iterable( - states.itervalues())): + for attr, value in itertools.chain.from_iterable(states.itervalues())): continue - f = { + field_value = { 'id': name, 'name': name, 'string': field['string'], @@ -146,60 +146,64 @@ class ir_import(orm.TransientModel): } if field['type'] in ('many2many', 'many2one'): - f['fields'] = [ - dict(f, name='id', string=_("External ID"), type='id'), - dict(f, name='.id', string=_("Database ID"), type='id'), + field_value['fields'] = [ + dict(field_value, name='id', string=_("External ID"), type='id'), + dict(field_value, name='.id', string=_("Database ID"), type='id'), ] elif field['type'] == 'one2many' and depth: - f['fields'] = self.get_fields( - cr, uid, field['relation'], context=context, depth=depth-1) - if self.user_has_groups(cr, uid, 'base.group_no_one'): - f['fields'].append({'id' : '.id', 'name': '.id', 'string': _("Database ID"), 'required': False, 'fields': [], 'type': 'id'}) + field_value['fields'] = self.get_fields(field['relation'], depth=depth-1) + if self.user_has_groups('base.group_no_one'): + field_value['fields'].append({'id': '.id', 'name': '.id', 'string': _("Database ID"), 'required': False, 'fields': [], 'type': 'id'}) - fields.append(f) + importable_fields.append(field_value) # TODO: cache on model? - return fields + return importable_fields - def _read_file(self, file_type, record, options): + @api.multi + def _read_file(self, options): + """ Dispatch to specific method to read file content, according to its mimetype or file type + :param options : dict of reading options (quoting, separator, ...) + """ + self.ensure_one() # guess mimetype from file content - mimetype = guess_mimetype(record.file) + mimetype = guess_mimetype(self.file) (file_extension, handler, req) = FILE_TYPE_DICT.get(mimetype, (None, None, None)) if handler: try: - return getattr(self, '_read_' + file_extension)(record, options) + return getattr(self, '_read_' + file_extension)(options) except Exception: - _logger.warn("Failed to read file '%s' (transient id %d) using guessed mimetype %s", - record.file_name or '', record.id, mimetype) + _logger.warn("Failed to read file '%s' (transient id %d) using guessed mimetype %s", self.file_name or '', self.id, mimetype) # try reading with user-provided mimetype - (file_extension, handler, req) = FILE_TYPE_DICT.get(file_type, (None, None, None)) + (file_extension, handler, req) = FILE_TYPE_DICT.get(self.file_type, (None, None, None)) if handler: try: - return getattr(self, '_read_' + file_extension)(record, options) + return getattr(self, '_read_' + file_extension)(options) except Exception: - _logger.warn("Failed to read file '%s' (transient id %d) using user-provided mimetype %s", - record.file_name or '', record.id, file_type) + _logger.warn("Failed to read file '%s' (transient id %d) using user-provided mimetype %s", self.file_name or '', self.id, self.file_type) # fallback on file extensions as mime types can be unreliable (e.g. # software setting incorrect mime types, or non-installed software # leading to browser not sending mime types) - if record.file_name: - p, ext = os.path.splitext(record.file_name) + if self.file_name: + p, ext = os.path.splitext(self.file_name) if ext in EXTENSIONS: try: - return getattr(self, '_read_' + ext[1:])(record, options) + return getattr(self, '_read_' + ext[1:])(options) except Exception: - _logger.warn("Failed to read file '%s' (transient id %s) using file extension", - record.file_name, record.id) + _logger.warn("Failed to read file '%s' (transient id %s) using file extension", self.file_name, self.id) if req: raise ImportError(_("Unable to load \"{extension}\" file: requires Python module \"{modname}\"").format(extension=file_extension, modname=req)) - raise ValueError(_("Unsupported file format \"{}\", import only supports CSV, ODS, XLS and XLSX").format(file_type)) + raise ValueError(_("Unsupported file format \"{}\", import only supports CSV, ODS, XLS and XLSX").format(self.file_type)) - def _read_xls(self, record, options): - book = xlrd.open_workbook(file_contents=record.file) + @api.multi + def _read_xls(self, options): + """ Read file content, using xlrd lib """ + book = xlrd.open_workbook(file_contents=self.file) return self._read_xls_book(book) + def _read_xls_book(self, book): sheet = book.sheet_by_index(0) # emulate Sheet.get_rows for pre-0.9.4 @@ -216,8 +220,7 @@ class ir_import(orm.TransientModel): elif cell.ctype is xlrd.XL_CELL_DATE: is_datetime = cell.value % 1 != 0.0 # emulate xldate_as_datetime for pre-0.9.3 - dt = datetime.datetime(*xlrd.xldate.xldate_as_tuple( - cell.value, book.datemode)) + dt = datetime.datetime(*xlrd.xldate.xldate_as_tuple(cell.value, book.datemode)) values.append( dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT) if is_datetime @@ -235,10 +238,14 @@ class ir_import(orm.TransientModel): values.append(cell.value) if any(x for x in values if x.strip()): yield values + + # use the same method for xlsx and xls files _read_xlsx = _read_xls - def _read_ods(self, record, options): - doc = odf_ods_reader.ODSReader(file=io.BytesIO(record.file)) + @api.multi + def _read_ods(self, options): + """ Read file content using ODSReader custom lib """ + doc = odf_ods_reader.ODSReader(file=io.BytesIO(self.file)) return ( row @@ -246,13 +253,13 @@ class ir_import(orm.TransientModel): if any(x for x in row if x.strip()) ) - def _read_csv(self, record, options): + @api.multi + def _read_csv(self, options): """ Returns a CSV-parsed iterator of all empty lines in the file - - :throws csv.Error: if an error is detected during CSV parsing - :throws UnicodeDecodeError: if ``options.encoding`` is incorrect + :throws csv.Error: if an error is detected during CSV parsing + :throws UnicodeDecodeError: if ``options.encoding`` is incorrect """ - csv_data = record.file + csv_data = self.file # TODO: guess encoding with chardet? Or https://github.com/aadsm/jschardet encoding = options.get('encoding', 'utf-8') @@ -271,7 +278,12 @@ class ir_import(orm.TransientModel): if any(x for x in row if x.strip()) ) - def _try_match_column(self, cr, uid, index, preview_values, fields, options): + @api.model + def _try_match_column(self, preview_values, options): + """ Returns the potential field types, based on the preview values, using heuristics + :param preview_values : list of value for the column to determine + :param options : parsing options + """ # If all values are empty in preview than can be any field if all([v == '' for v in preview_values]): return ['all'] @@ -289,7 +301,7 @@ class ir_import(orm.TransientModel): except ValueError: pass # If all values are either True or False, type is boolean - if all(val.lower() in ('true','false','t','f','') for val in preview_values): + if all(val.lower() in ('true', 'false', 't', 'f', '') for val in preview_values): return ['boolean'] # If all values can be cast to float, type is either float or monetary try: @@ -298,11 +310,11 @@ class ir_import(orm.TransientModel): if val == '': continue # value might have the currency symbol left or right from the value - val = self._remove_currency_symbol(cr, uid, val) + val = self._remove_currency_symbol(val) if val: if options.get('float_thousand_separator') and options.get('float_decimal_separator'): val = val.replace(options['float_thousand_separator'], '').replace(options['float_decimal_separator'], '.') - # We are now sure that this is a float, but we still need to find the + # We are now sure that this is a float, but we still need to find the # thousand and decimal separator else: if val.count('.') > 1: @@ -328,12 +340,12 @@ class ir_import(orm.TransientModel): pass # Try to see if all values are a date or datetime dt = datetime.datetime - separator = [' ','/','-'] + separator = [' ', '/', '-'] date_format = ['%mr%dr%Y', '%dr%mr%Y', '%Yr%mr%d', '%Yr%dr%m'] date_patterns = [options['date_format']] if options.get('date_format') else [] if not date_patterns: date_patterns = [pattern.replace('r', sep) for sep in separator for pattern in date_format] - date_patterns.extend([p.replace('Y','y') for p in date_patterns]) + date_patterns.extend([p.replace('Y', 'y') for p in date_patterns]) current_date_pattern = False for date_pattern in date_patterns: date_ok = True @@ -360,26 +372,26 @@ class ir_import(orm.TransientModel): return ['text', 'char', 'datetime', 'selection', 'many2one', 'one2many', 'many2many', 'html'] - def _find_type_from_preview(self, cr, uid, fields, options, preview): + @api.model + def _find_type_from_preview(self, options, preview): type_fields = [] if preview: - for column in range(0,len(preview[0])): + for column in range(0, len(preview[0])): preview_values = [value[column].strip() for value in preview] - type_field = self._try_match_column(cr, uid, column, preview_values, fields, options) + type_field = self._try_match_column(preview_values, options) type_fields.append(type_field) return type_fields - def _match_header(self, header, fields, options): """ Attempts to match a given header to a field of the - imported model. + imported model. - :param str header: header name from the CSV file - :param fields: - :param dict options: - :returns: an empty list if the header couldn't be matched, or - all the fields to traverse - :rtype: list(Field) + :param str header: header name from the CSV file + :param fields: + :param dict options: + :returns: an empty list if the header couldn't be matched, or + all the fields to traverse + :rtype: list(Field) """ string_match = None for field in fields: @@ -407,7 +419,8 @@ class ir_import(orm.TransientModel): # readability of paths match = self._match_header(section.strip(), subfields, options) # Any match failure, exit - if not match: return [] + if not match: + return [] # prep subfields for next iteration within match[0] field = match[0] subfields = field['fields'] @@ -416,56 +429,55 @@ class ir_import(orm.TransientModel): def _match_headers(self, rows, fields, options): """ Attempts to match the imported model's fields to the - titles of the parsed CSV file, if the file is supposed to have - headers. + titles of the parsed CSV file, if the file is supposed to have + headers. - 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 - or the list of headers and a dict mapping cell indices - to key paths in the ``fields`` tree + Returns a pair of (None, None) if headers were not requested + or the list of headers and a dict mapping cell indices + to key paths in the ``fields`` tree - :param Iterator rows: - :param dict fields: - :param dict options: - :rtype: (None, None) | (list(str), dict(int: list(str))) + :param Iterator rows: + :param dict fields: + :param dict options: + :rtype: (None, None) | (list(str), dict(int: list(str))) """ if not options.get('headers'): return None, None headers = next(rows) - return headers, { + return headers, { index: [field['name'] for field in self._match_header(header, fields, options)] or None for index, header in enumerate(headers) } - def parse_preview(self, cr, uid, id, options, count=10, context=None): + @api.multi + def parse_preview(self, options, count=10): """ Generates a preview of the uploaded files, and performs - fields-matching between the import's file data and the model's - columns. + fields-matching between the import's file data and the model's + columns. - If the headers are not requested (not options.headers), - ``matches`` and ``headers`` are both ``False``. + If the headers are not requested (not options.headers), + ``matches`` and ``headers`` are both ``False``. - :param id: identifier of the import - :param int count: number of preview lines to generate - :param options: format-specific options. - CSV: {encoding, quoting, separator, headers} - :type options: {str, str, str, bool} - :returns: {fields, matches, headers, preview} | {error, preview} - :rtype: {dict(str: dict(...)), dict(int, list(str)), list(str), list(list(str))} | {str, str} + :param int count: number of preview lines to generate + :param options: format-specific options. + CSV: {encoding, quoting, separator, headers} + :type options: {str, str, str, bool} + :returns: {fields, matches, headers, preview} | {error, preview} + :rtype: {dict(str: dict(...)), dict(int, list(str)), list(str), list(list(str))} | {str, str} """ - - (record,) = self.browse(cr, uid, [id], context=context) - fields = self.get_fields(cr, uid, record.res_model, context=context) + self.ensure_one() + fields = self.get_fields(self.res_model) try: - rows = self._read_file(record.file_type, record, options) + rows = self._read_file(options) headers, matches = self._match_headers(rows, fields, options) # Match should have consumed the first row (iif headers), get # the ``count`` next rows for preview preview = list(itertools.islice(rows, count)) assert preview, "CSV file seems to have no content" - header_types = self._find_type_from_preview(cr, uid, fields, options, preview) + header_types = self._find_type_from_preview(options, preview) if options.get('keep_matches', False) and len(options.get('fields', [])): matches = {} for index, match in enumerate(options.get('fields')): @@ -479,18 +491,18 @@ class ir_import(orm.TransientModel): 'headers_type': header_types or False, 'preview': preview, 'options': options, - 'debug': self.user_has_groups(cr, uid, 'base.group_no_one'), + 'debug': self.user_has_groups('base.group_no_one'), } - except Exception, e: + except Exception, error: # Due to lazy generators, UnicodeDecodeError (for # instance) may only be raised when serializing the # preview to a list in the return. _logger.debug("Error during parsing preview", exc_info=True) preview = None - if record.file_type == 'text/csv': - preview = record.file[:ERROR_PREVIEW_BYTES].decode('iso-8859-1') + if self.file_type == 'text/csv': + preview = self.file[:ERROR_PREVIEW_BYTES].decode('iso-8859-1') return { - 'error': str(e), + 'error': str(error), # iso-8859-1 ensures decoding will always succeed, # even if it yields non-printable characters. This is # in case of UnicodeDecodeError (or csv.Error @@ -498,17 +510,17 @@ class ir_import(orm.TransientModel): 'preview': preview, } - def _convert_import_data(self, record, fields, options, context=None): + @api.model + def _convert_import_data(self, fields, options): """ Extracts the input browse_record and fields list (with - ``False``-y placeholders for fields to *not* import) into a - format Model.load can use: a fields list without holes - and the precisely matching data matrix + ``False``-y placeholders for fields to *not* import) into a + format Model.import_data can use: a fields list without holes + and the precisely matching data matrix - :param browse_record record: - :param list(str|bool): fields - :returns: (data, fields) - :rtype: (list(list(str)), list(str)) - :raises ValueError: in case the import data could not be converted + :param list(str|bool): fields + :returns: (data, fields) + :rtype: (list(list(str)), list(str)) + :raises ValueError: in case the import data could not be converted """ # Get indices for non-empty fields indices = [index for index, field in enumerate(fields) if field] @@ -516,15 +528,16 @@ class ir_import(orm.TransientModel): raise ValueError(_("You must configure at least one field to import")) # If only one index, itemgetter will return an atom rather # than a 1-tuple - if len(indices) == 1: mapper = lambda row: [row[indices[0]]] - else: mapper = operator.itemgetter(*indices) + if len(indices) == 1: + mapper = lambda row: [row[indices[0]]] + else: + mapper = operator.itemgetter(*indices) # Get only list of actually imported fields import_fields = filter(None, fields) - rows_to_import = self._read_file(record.file_type, record, options) + rows_to_import = self._read_file(options) if options.get('headers'): - rows_to_import = itertools.islice( - rows_to_import, 1, None) + rows_to_import = itertools.islice(rows_to_import, 1, None) data = [ list(row) for row in itertools.imap(mapper, rows_to_import) # don't try inserting completely empty rows (e.g. from @@ -534,7 +547,8 @@ class ir_import(orm.TransientModel): return data, import_fields - def _remove_currency_symbol(self, cr, uid, value): + @api.model + def _remove_currency_symbol(self, value): value = value.strip() negative = False # Careful that some countries use () for negative so replace it by - sign @@ -548,21 +562,22 @@ class ir_import(orm.TransientModel): return False if len(split_value) == 1: if float_regex.search(split_value[0]) is not None: - return split_value[0] if not negative else '-'+split_value[0] - return False + return split_value[0] if not negative else '-' + split_value[0] + return False else: # String has been split in 2, locate which index contains the float and which does not currency_index = 0 if float_regex.search(split_value[0]) is not None: currency_index = 1 # Check that currency exists - currency = self.pool.get('res.currency').search(cr, uid, [('symbol', '=', split_value[currency_index].strip())]) + currency = self.env['res.currency'].search([('symbol', '=', split_value[currency_index].strip())]) if len(currency): - return split_value[(currency_index+1)%2] if not negative else '-'+split_value[(currency_index+1)%2] + return split_value[currency_index + 1 % 2] if not negative else '-' + split_value[currency_index + 1 % 2] # Otherwise it is not a float with a currency symbol return False - def _parse_float_from_data(self, cr, uid, data, index, name, options, context=None): + @api.model + def _parse_float_from_data(self, data, index, name, options): thousand_separator = options.get('float_thousand_separator', ' ') decimal_separator = options.get('float_decimal_separator', '.') for line in data: @@ -570,13 +585,14 @@ class ir_import(orm.TransientModel): continue line[index] = line[index].replace(thousand_separator, '').replace(decimal_separator, '.') old_value = line[index] - line[index] = self._remove_currency_symbol(cr, uid, line[index]) - if line[index] == False: + line[index] = self._remove_currency_symbol(line[index]) + if line[index] is False: raise ValueError(_("Column %s contains incorrect values (value: %s)" % (name, old_value))) - def _parse_import_data(self, cr, uid, data, import_fields, record, options, context=None): + @api.multi + def _parse_import_data(self, data, import_fields, options): # Get fields of type date/datetime - all_fields = self.pool[record.res_model].fields_get(cr, uid, context=context) + all_fields = self.env[self.res_model].fields_get() for name, field in all_fields.iteritems(): if field['type'] in ('date', 'datetime') and name in import_fields: # Parse date @@ -592,12 +608,13 @@ class ir_import(orm.TransientModel): raise ValueError(_("Column %s contains incorrect values (value: %s does not match date format" % (name, line[index]))) elif field['type'] in ('float', 'monetary') and name in import_fields: # Parse float, sometimes float values from file have currency symbol or () to denote a negative value - # We should be able to manage both case + # We should be able to manage both case index = import_fields.index(name) - self._parse_float_from_data(cr, uid, data, index, name, options, context=context) + self._parse_float_from_data(data, index, name, options) return data - def do(self, cr, uid, id, fields, options, dryrun=False, context=None): + @api.multi + def do(self, fields, options, dryrun=False): """ Actual execution of the import :param fields: import mapping: maps each column to a field, @@ -617,24 +634,22 @@ class ir_import(orm.TransientModel): ``false`` if that data isn't available or provided) :rtype: list({type, message, record}) """ - cr.execute('SAVEPOINT import') + self.ensure_one() + self._cr.execute('SAVEPOINT import') - (record,) = self.browse(cr, uid, [id], context=context) try: - data, import_fields = self._convert_import_data( - record, fields, options, context=context) + data, import_fields = self._convert_import_data(fields, options) # Parse date and float field - data = self._parse_import_data(cr, uid, data, import_fields, record, options, context=context) - except ValueError, e: + data = self._parse_import_data(data, import_fields, options) + except ValueError, error: return [{ 'type': 'error', - 'message': unicode(e), + 'message': unicode(error), 'record': False, }] _logger.info('importing %d rows...', len(data)) - import_result = self.pool[record.res_model].load( - cr, uid, import_fields, data, context=context) + import_result = self.env[self.res_model].load(import_fields, data) _logger.info('done') # If transaction aborted, RELEASE SAVEPOINT is going to raise @@ -646,9 +661,9 @@ class ir_import(orm.TransientModel): # error in the results. try: if dryrun: - cr.execute('ROLLBACK TO SAVEPOINT import') + self._cr.execute('ROLLBACK TO SAVEPOINT import') else: - cr.execute('RELEASE SAVEPOINT import') + self._cr.execute('RELEASE SAVEPOINT import') except psycopg2.InternalError: pass diff --git a/addons/base_import/tests/test_base_import.py b/addons/base_import/tests/test_base_import.py index d9b6e16b2f4..b65cf7dc9cc 100644 --- a/addons/base_import/tests/test_base_import.py +++ b/addons/base_import/tests/test_base_import.py @@ -72,7 +72,7 @@ class test_basic_fields(BaseImportCase): {'id': 'value', 'name': 'id', 'string': 'External ID', 'required': False, 'fields': [], 'type': 'id'}, {'id': 'value', 'name': '.id', 'string': 'Database ID', 'required': False, 'fields': [], 'type': 'id'}, ])) - + def test_m2o_required(self): """ If an m2o field is required, its three sub-fields are required as well (the client has to handle that: requiredness