diff --git a/CHANGELOG b/CHANGELOG
index 82992ac1bd..c082756c06 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,4 @@
+* Add MultiSelection entry to Dict field
* Allow empty order clause
* Change editable tree attribute into boolean
* Send default order to clients
diff --git a/trytond/ir/message.xml b/trytond/ir/message.xml
index 53e6f94d59..b252a7357b 100644
--- a/trytond/ir/message.xml
+++ b/trytond/ir/message.xml
@@ -90,6 +90,9 @@ this repository contains the full copyright notices and license terms. -->
Selection
+
+ MultiSelection
+
Digits
diff --git a/trytond/model/dictschema.py b/trytond/model/dictschema.py
index 0e2c570db1..f03e865abf 100644
--- a/trytond/model/dictschema.py
+++ b/trytond/model/dictschema.py
@@ -42,6 +42,8 @@ class DictSchemaMixin(object):
('date', lazy_gettext('ir.msg_dict_schema_date')),
('datetime', lazy_gettext('ir.msg_dict_schema_datetime')),
('selection', lazy_gettext('ir.msg_dict_schema_selection')),
+ ('multiselection',
+ lazy_gettext('ir.msg_dict_schema_multiselection')),
], lazy_gettext('ir.msg_dict_schema_type'), required=True)
digits = fields.Integer(
lazy_gettext('ir.msg_dict_schema_digits'),
@@ -52,19 +54,20 @@ class DictSchemaMixin(object):
selection = fields.Text(
lazy_gettext('ir.msg_dict_schema_selection'),
states={
- 'invisible': Eval('type_') != 'selection',
+ 'invisible': ~Eval('type_').in_(['selection', 'multiselection']),
}, translate=True, depends=['type_'],
help=lazy_gettext('ir.msg_dict_schema_selection_help'))
selection_sorted = fields.Boolean(
lazy_gettext('ir.msg_dict_schema_selection_sorted'),
states={
- 'invisible': Eval('type_') != 'selection',
+ 'invisible': ~Eval('type_').in_(['selection', 'multiselection']),
}, depends=['type_'],
help=lazy_gettext('ir.msg_dict_schema_selection_sorted_help'))
selection_json = fields.Function(fields.Char(
lazy_gettext('ir.msg_dict_schema_selection_json'),
states={
- 'invisible': Eval('type_') != 'selection',
+ 'invisible': ~Eval('type_').in_(
+ ['selection', 'multiselection']),
},
depends=['type_']), 'get_selection_json')
@@ -117,7 +120,7 @@ class DictSchemaMixin(object):
@classmethod
def check_selection(cls, schemas):
for schema in schemas:
- if schema.type_ != 'selection':
+ if schema.type_ not in {'selection', 'multiselection'}:
continue
try:
dict(json.loads(schema.get_selection_json()))
@@ -147,7 +150,7 @@ class DictSchemaMixin(object):
'domain': record.domain,
'sequence': getattr(record, 'sequence', record.name),
}
- if record.type_ == 'selection':
+ if record.type_ in {'selection', 'multiselection'}:
with Transaction().set_context(language=Config.get_language()):
english_key = cls(record.id)
selection = OrderedDict(json.loads(
diff --git a/trytond/model/fields/dict.py b/trytond/model/fields/dict.py
index 58172682c3..b34c92019c 100644
--- a/trytond/model/fields/dict.py
+++ b/trytond/model/fields/dict.py
@@ -47,7 +47,14 @@ class Dict(Field):
def sql_format(self, value):
value = super().sql_format(value)
if isinstance(value, dict):
- value = dumps({k: v for k, v in value.items() if v is not None})
+ d = {}
+ for k, v in value.items():
+ if v is None:
+ continue
+ if isinstance(v, list):
+ v = list(sorted(set(v)))
+ d[k] = v
+ value = dumps(d)
return value
def translated(self, name=None, type_='values'):
@@ -74,6 +81,8 @@ class Dict(Field):
value = int(value)
if isinstance(value, (Select, CombiningQuery)):
return value
+ if isinstance(value, (list, tuple)):
+ value = sorted(set(value))
if operator.endswith('in'):
return [dumps(v) for v in value]
else:
@@ -101,33 +110,46 @@ class Dict(Field):
table, _ = tables[None]
name, key = name.split('.', 1)
Operator = SQL_OPERATORS[operator]
- column = self.sql_column(table)
- column = self._domain_column(operator, column, key)
+ raw_column = self.sql_column(table)
+ column = self._domain_column(operator, raw_column, key)
expression = Operator(column, self._domain_value(operator, value))
if operator in {'=', '!='}:
# Try to use custom operators in case there is indexes
- raw_column = self.sql_column(table)
try:
if value is None:
expression = database.json_key_exists(
raw_column, key)
if operator == '=':
expression = operators.Not(expression)
- return expression
- else:
+ # we compare on multi-selection by doing an equality check and
+ # not a contain check
+ elif not isinstance(value, (list, tuple)):
expression = database.json_contains(
raw_column, dumps({key: value}))
if operator == '!=':
expression = operators.Not(expression)
expression &= database.json_key_exists(
raw_column, key)
- return expression
+ return expression
except NotImplementedError:
pass
- if isinstance(expression, operators.In) and not expression.right:
- expression = Literal(False)
- elif isinstance(expression, operators.NotIn) and not expression.right:
- expression = Literal(True)
+ elif operator.endswith('in'):
+ # Try to use custom operators in case there is indexes
+ if not value:
+ expression = Literal(operator.startswith('not'))
+ else:
+ op = '!=' if operator.startswith('not') else '='
+ try:
+ in_expr = Literal(False)
+ for v in value:
+ in_expr |= database.json_contains(
+ self._domain_column(op, raw_column, key),
+ dumps(v))
+ if operator.startswith('not'):
+ in_expr = ~in_expr
+ expression = in_expr
+ except NotImplementedError:
+ pass
expression = self._domain_add_null(column, operator, value, expression)
return expression
diff --git a/trytond/tests/test_field_dict.py b/trytond/tests/test_field_dict.py
index d8c28e46d4..37e2b103da 100644
--- a/trytond/tests/test_field_dict.py
+++ b/trytond/tests/test_field_dict.py
@@ -36,6 +36,17 @@ class FieldDictTestCase(unittest.TestCase):
'type_': 'selection',
'selection': ('arabic: Arabic\n'
'hexa: Hexadecimal'),
+ }, {
+ 'name': 'countries',
+ 'string': 'Countries',
+ 'type_': 'multiselection',
+ 'selection': (
+ 'au: Australia\n'
+ 'be: Belgium\n'
+ 'ca: Canada\n'
+ 'de: Germany\n'
+ 'es: Spain\n'
+ 'fr: France'),
}])
def set_jsonb(self, table):
@@ -130,6 +141,18 @@ class FieldDictTestCase(unittest.TestCase):
self.assertDictEqual(dict_.dico, {'type': 'arabic'})
+ @with_transaction()
+ def test_create_multiselection(self):
+ "Test create dict with multi-selection"
+ Dict = Pool().get('test.dict')
+ self.create_schema()
+
+ dict_, = Dict.create([{
+ 'dico': {'countries': ['fr', 'be']},
+ }])
+
+ self.assertDictEqual(dict_.dico, {'countries': ['be', 'fr']})
+
@with_transaction()
def test_invalid_selection_schema(self):
"Test invalid selection schema"
@@ -234,6 +257,31 @@ class FieldDictTestCase(unittest.TestCase):
self.assertListEqual(dicts_bar, [])
self.assertListEqual(dicts_foo_b, [])
+ @with_transaction()
+ def test_search_multiselection_equals(self):
+ "Test search dict multi-selection equals"
+ pool = Pool()
+ Dict = pool.get('test.dict')
+ self.create_schema()
+
+ dict_, = Dict.create([{
+ 'dico': {'countries': ['fr', 'be']},
+ }])
+
+ france_belgium = Dict.search([
+ ('dico.countries', '=', ['be', 'fr']),
+ ])
+ belgium = Dict.search([
+ ('dico.countries', '=', ['be']),
+ ])
+ germany = Dict.search([
+ ('dico.countries', '=', ['de']),
+ ])
+
+ self.assertEqual(france_belgium, [dict_])
+ self.assertEqual(belgium, [])
+ self.assertEqual(germany, [])
+
@with_transaction()
def test_search_element_equals_none(self):
"Test search dict element equals None"
@@ -290,6 +338,35 @@ class FieldDictTestCase(unittest.TestCase):
self.assertListEqual(dicts_bar, [dict_])
self.assertListEqual(dicts_foo_b, [])
+ @with_transaction()
+ def test_search_multiselection_not_equals(self):
+ "Test search dict multi-selection not equals"
+ pool = Pool()
+ Dict = pool.get('test.dict')
+ self.create_schema()
+
+ dict_, = Dict.create([{
+ 'dico': {'countries': ['fr', 'be']},
+ }])
+
+ not_france_belgium = Dict.search([
+ ('dico.countries', '!=', ['be', 'fr']),
+ ])
+ not_belgium = Dict.search([
+ ('dico.countries', '!=', ['be']),
+ ])
+ not_germany = Dict.search([
+ ('dico.countries', '!=', ['de']),
+ ])
+ not_empty = Dict.search([
+ ('dico.countries', '!=', []),
+ ])
+
+ self.assertEqual(not_france_belgium, [])
+ self.assertEqual(not_belgium, [dict_])
+ self.assertEqual(not_germany, [dict_])
+ self.assertEqual(not_empty, [dict_])
+
@with_transaction()
def test_search_element_non_equals_none(self):
"Test search dict element non equals None"
@@ -402,6 +479,38 @@ class FieldDictTestCase(unittest.TestCase):
self.assertListEqual(dicts_empty, [])
self.assertListEqual(dicts_foo_b, [])
+ @with_transaction()
+ @unittest.skipIf(
+ backend.name != 'postgresql',
+ 'in use the contain check specific to postgresql')
+ def test_search_multiselection_in(self):
+ "Test search dict multi-selection with in"
+ pool = Pool()
+ Dict = pool.get('test.dict')
+ self.create_schema()
+
+ dict_, = Dict.create([{
+ 'dico': {'countries': ['fr', 'be']},
+ }])
+
+ belgium = Dict.search([
+ ('dico.countries', 'in', ['be']),
+ ])
+ germany = Dict.search([
+ ('dico.countries', 'in', ['de']),
+ ])
+ belgium_germany = Dict.search([
+ ('dico.countries', 'in', ['be', 'de']),
+ ])
+ empty = Dict.search([
+ ('dico.countries', 'in', []),
+ ])
+
+ self.assertEqual(belgium, [dict_])
+ self.assertEqual(germany, [])
+ self.assertEqual(belgium_germany, [dict_])
+ self.assertEqual(empty, [])
+
@with_transaction()
def test_search_element_in_none(self):
"Test search dict element in [None]"
@@ -446,6 +555,38 @@ class FieldDictTestCase(unittest.TestCase):
self.assertListEqual(dicts_empty, [dict_])
self.assertListEqual(dicts_foo_b, [])
+ @with_transaction()
+ @unittest.skipIf(
+ backend.name != 'postgresql',
+ 'in use the contain check specific to postgresql')
+ def test_search_multiselection_not_in(self):
+ "Test search dict multi-selection with not in"
+ pool = Pool()
+ Dict = pool.get('test.dict')
+ self.create_schema()
+
+ dict_, = Dict.create([{
+ 'dico': {'countries': ['fr', 'be']},
+ }])
+
+ not_belgium = Dict.search([
+ ('dico.countries', 'not in', ['be']),
+ ])
+ not_germany = Dict.search([
+ ('dico.countries', 'not in', ['de']),
+ ])
+ not_belgium_germany = Dict.search([
+ ('dico.countries', 'not in', ['de', 'be']),
+ ])
+ not_empty = Dict.search([
+ ('dico.countries', 'not in', []),
+ ])
+
+ self.assertEqual(not_belgium, [])
+ self.assertEqual(not_germany, [dict_])
+ self.assertEqual(not_belgium_germany, [])
+ self.assertEqual(not_empty, [dict_])
+
@with_transaction()
def test_search_element_not_in_none(self):
"Test search dict element not in [None]"