From 91f63609e88c9efdc3cbee8b8bf049f07f4f7c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 20 Sep 2019 11:32:56 +0200 Subject: [PATCH] Add MultiSelection field issue7851 review249911002 --- CHANGELOG | 1 + tryton/common/domain_parser.py | 104 +++++++++++++++++- tryton/gui/window/view_form/model/field.py | 16 ++- .../view_form/view/form_gtk/multiselection.py | 18 ++- tryton/gui/window/view_form/view/list.py | 5 +- .../window/view_form/view/list_gtk/widget.py | 27 +++++ .../window/view_form/view/screen_container.py | 2 +- 7 files changed, 156 insertions(+), 17 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index f2b8e88dc2..cef3f8f17c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Support multiselection field * Support dot notation on PYSON Eval * Order Dict keys by sequence * Support relation fields in domain parser diff --git a/tryton/common/domain_parser.py b/tryton/common/domain_parser.py index 4a181d0e0a..ccedb2d696 100644 --- a/tryton/common/domain_parser.py +++ b/tryton/common/domain_parser.py @@ -177,6 +177,8 @@ def default_operator(field): if field['type'] in ('char', 'text', 'many2one', 'many2many', 'one2many', 'reference'): return 'ilike' + elif field['type'] == 'multiselection': + return 'in' else: return '=' @@ -307,6 +309,7 @@ def convert_value(field, value, context=None): 'integer': convert_integer, 'numeric': convert_numeric, 'selection': convert_selection, + 'multiselection': convert_selection, 'reference': convert_selection, 'datetime': convert_datetime, 'date': convert_date, @@ -563,6 +566,7 @@ def format_value(field, value, target=None, context=None): 'float': format_float, 'numeric': format_float, 'selection': format_selection, + 'multiselection': format_selection, 'reference': format_reference, 'datetime': format_datetime, 'date': format_date, @@ -786,6 +790,7 @@ def complete_value(field, value): completes = { 'boolean': complete_boolean, 'selection': complete_selection, + 'multiselection': complete_selection, 'reference': complete_reference, 'datetime': complete_datetime, 'date': complete_date, @@ -1023,6 +1028,8 @@ class DomainParser(object): return all(isinstance(v, types) for v in value) else: return isinstance(value, types) + elif field['type'] == 'multiselection': + return not value or isinstance(value, list) else: return True elif name == 'rec_name': @@ -1270,16 +1277,25 @@ class DomainParser(object): target, value = split_target_value(field, value) if target: field_name += '.rec_name' + elif field['type'] == 'multiselection': + if value is not None and not isinstance(value, list): + value = [value] if not operator: operator = default_operator(field) - if isinstance(value, list): + if (isinstance(value, list) + and field['type'] != 'multiselection'): if operator == '!': operator = 'not in' else: operator = 'in' if operator == '!': operator = negate_operator(default_operator(field)) + if value is None and operator.endswith('in'): + if operator.startswith('not'): + operator = '!=' + else: + operator = '=' if field['type'] in ('integer', 'float', 'numeric', 'datetime', 'date', 'time'): if value and '..' in value: @@ -1313,6 +1329,15 @@ def test_stringable(): 'string': 'Name', 'type': 'char', }, + 'multiselection': { + 'string': "MultiSelection", + 'type': 'multiselection', + 'selection': [ + ('foo', "Foo"), + ('bar', "Bar"), + ('baz', "Baz"), + ], + }, 'relation': { 'string': 'Relation', 'type': 'many2one', @@ -1336,6 +1361,10 @@ def test_stringable(): assert not dom.stringable(['AND', valid, invalid]) assert dom.stringable([[valid]]) assert not dom.stringable([[valid], [invalid]]) + assert dom.stringable([('multiselection', '=', None)]) + assert dom.stringable([('multiselection', '=', '')]) + assert not dom.stringable([('multiselection', '=', 'foo')]) + assert dom.stringable([('multiselection', '=', ['foo'])]) assert dom.stringable([('relation', '=', None)]) assert dom.stringable([('relation', '=', "Foo")]) assert dom.stringable([('relation.rec_name', '=', "Foo")]) @@ -1369,6 +1398,15 @@ def test_string(): ('', ''), ], }, + 'multiselection': { + 'string': "MultiSelection", + 'type': 'multiselection', + 'selection': [ + ('foo', "Foo"), + ('bar', "Bar"), + ('baz', "Baz"), + ], + }, 'reference': { 'string': 'Reference', 'type': 'reference', @@ -1426,6 +1464,29 @@ def test_string(): assert dom.string([('selection', '!=', '')]) == 'Selection: !""' assert dom.string([('selection', '=', 'male')]) == 'Selection: Male' assert dom.string([('selection', '!=', 'male')]) == 'Selection: !Male' + assert dom.string([('multiselection', '=', None)]) == "MultiSelection: =" + assert dom.string([('multiselection', '=', '')]) == "MultiSelection: =" + assert dom.string([('multiselection', '!=', '')]) == "MultiSelection: !=" + assert dom.string([('multiselection', '=', ['foo'])]) == \ + "MultiSelection: =Foo" + assert dom.string([('multiselection', '!=', ['foo'])]) == \ + "MultiSelection: !=Foo" + assert dom.string([('multiselection', '=', ['foo', 'bar'])]) == \ + "MultiSelection: =Foo;Bar" + assert dom.string([('multiselection', '!=', ['foo', 'bar'])]) == \ + "MultiSelection: !=Foo;Bar" + assert dom.string([('multiselection', 'in', ['foo'])]) == \ + "MultiSelection: Foo" + assert dom.string([('multiselection', 'not in', ['foo'])]) == \ + "MultiSelection: !Foo" + assert dom.string([('multiselection', '=', ['foo', 'bar'])]) == \ + "MultiSelection: =Foo;Bar" + assert dom.string([('multiselection', '!=', ['foo', 'bar'])]) == \ + "MultiSelection: !=Foo;Bar" + assert dom.string([('multiselection', 'in', ['foo', 'bar'])]) == \ + "MultiSelection: Foo;Bar" + assert dom.string([('multiselection', 'not in', ['foo', 'bar'])]) == \ + "MultiSelection: !Foo;Bar" assert dom.string([('reference', 'ilike', '%foo%')]) == \ 'Reference: foo' assert dom.string([('reference.rec_name', 'ilike', '%bar%', 'spam')]) == \ @@ -1565,6 +1626,16 @@ def test_parse_clause(): ('female', 'Female'), ], }, + 'multiselection': { + 'string': "MultiSelection", + 'name': 'multiselection', + 'type': 'multiselection', + 'selection': [ + ('foo', "Foo"), + ('bar', "Bar"), + ('baz', "Baz"), + ], + }, 'reference': { 'string': 'Reference', 'name': 'reference', @@ -1622,6 +1693,37 @@ def test_parse_clause(): == [ ('selection', 'in', ['male', 'female']) ] + assert rlist(dom.parse_clause([('MultiSelection', None, None)])) == [ + ('multiselection', '=', None), + ] + assert rlist(dom.parse_clause([('MultiSelection', None, '')])) == [ + ('multiselection', 'in', ['']), + ] + assert rlist(dom.parse_clause([('MultiSelection', '=', '')])) == [ + ('multiselection', '=', ['']), + ] + assert rlist(dom.parse_clause([('MultiSelection', '!', '')])) == [ + ('multiselection', 'not in', ['']), + ] + assert rlist(dom.parse_clause([('MultiSelection', '!=', '')])) == [ + ('multiselection', '!=', ['']), + ] + assert rlist(dom.parse_clause( + [('MultiSelection', None, ['Foo', 'Bar'])])) == [ + ('multiselection', 'in', ['foo', 'bar']), + ] + assert rlist(dom.parse_clause( + [('MultiSelection', '=', ['Foo', 'Bar'])])) == [ + ('multiselection', '=', ['foo', 'bar']), + ] + assert rlist(dom.parse_clause( + [('MultiSelection', '!', ['Foo', 'Bar'])])) == [ + ('multiselection', 'not in', ['foo', 'bar']), + ] + assert rlist(dom.parse_clause( + [('MultiSelection', '!=', ['Foo', 'Bar'])])) == [ + ('multiselection', '!=', ['foo', 'bar']), + ] assert rlist(dom.parse_clause([('Integer', None, None)])) == [ ('integer', '=', None), ] diff --git a/tryton/gui/window/view_form/model/field.py b/tryton/gui/window/view_form/model/field.py index b37f59ff98..48281cc231 100644 --- a/tryton/gui/window/view_form/model/field.py +++ b/tryton/gui/window/view_form/model/field.py @@ -208,8 +208,19 @@ class SelectionField(Field): _default = None - def get_client(self, record): - return record.value.get(self.name) + +class MultiSelectionField(Field): + + _default = None + + def get(self, record): + return super().get(record) or self._default + + def get_eval(self, record): + value = super().get_eval(record) + if value is None: + value = [] + return value class DateTimeField(Field): @@ -1078,6 +1089,7 @@ TYPES = { 'one2many': O2MField, 'reference': ReferenceField, 'selection': SelectionField, + 'multiselection': MultiSelectionField, 'boolean': BooleanField, 'datetime': DateTimeField, 'date': DateField, diff --git a/tryton/gui/window/view_form/view/form_gtk/multiselection.py b/tryton/gui/window/view_form/view/form_gtk/multiselection.py index 3144d1f9ec..388004e38e 100644 --- a/tryton/gui/window/view_form/view/form_gtk/multiselection.py +++ b/tryton/gui/window/view_form/view/form_gtk/multiselection.py @@ -23,7 +23,7 @@ class MultiSelection(Widget, SelectionMixin): self.widget.set_size_request(100, 100) self.widget.get_accessible().set_name(attrs.get('string', '')) - self.model = Gtk.ListStore(GObject.TYPE_INT, GObject.TYPE_STRING) + self.model = Gtk.ListStore(GObject.TYPE_PYOBJECT, GObject.TYPE_STRING) self.tree = self.mnemonic_widget = TreeViewControl() self.tree.set_model(self.model) self.tree.set_search_column(1) @@ -41,7 +41,6 @@ class MultiSelection(Widget, SelectionMixin): self.nullable_widget = False self.init_selection() - self.id2path = {} def _readonly_set(self, readonly): super(MultiSelection, self)._readonly_set(readonly) @@ -51,7 +50,7 @@ class MultiSelection(Widget, SelectionMixin): @property def modified(self): if self.record and self.field: - group = set(r.id for r in self.field.get_client(self.record)) + group = set(self.field.get_eval(self.record)) value = set(self.get_value()) return value != group return False @@ -81,17 +80,14 @@ class MultiSelection(Widget, SelectionMixin): self.model.clear() if not self.field: return - id2path = {} + value2path = {} for idx, (value, name) in enumerate(self.selection): self.model.append((value, name)) - id2path[value] = idx + value2path[value] = idx selection.unselect_all() - group = self.field.get_client(self.record) - for element in group: - if (element not in group.record_removed - and element not in group.record_deleted - and element.id in id2path): - selection.select_path(id2path[element.id]) + values = self.field.get_eval(self.record) + for value in values: + selection.select_path(value2path[value]) super(MultiSelection, self).display() finally: selection.handler_unblock_by_func(self.changed) diff --git a/tryton/gui/window/view_form/view/list.py b/tryton/gui/window/view_form/view/list.py index 1466396d93..31d4fd1467 100644 --- a/tryton/gui/window/view_form/view/list.py +++ b/tryton/gui/window/view_form/view/list.py @@ -20,8 +20,8 @@ import tryton.common as common from . import View, XMLViewParser from .list_gtk.editabletree import EditableTreeView, TreeView from .list_gtk.widget import (Affix, Char, Text, Int, Boolean, URL, Date, - Time, Float, TimeDelta, Binary, M2O, O2O, O2M, M2M, Selection, Reference, - Dict, ProgressBar, Button, Image) + Time, Float, TimeDelta, Binary, M2O, O2O, O2M, M2M, Selection, + MultiSelection, Reference, Dict, ProgressBar, Button, Image) _ = gettext.gettext @@ -271,6 +271,7 @@ class TreeXMLViewParser(XMLViewParser): 'progressbar': ProgressBar, 'reference': Reference, 'selection': Selection, + 'multiselection': MultiSelection, 'sip': URL, 'text': Text, 'time': Time, diff --git a/tryton/gui/window/view_form/view/list_gtk/widget.py b/tryton/gui/window/view_form/view/list_gtk/widget.py index 68a3e2c5cf..e35a212a03 100644 --- a/tryton/gui/window/view_form/view/list_gtk/widget.py +++ b/tryton/gui/window/view_form/view/list_gtk/widget.py @@ -985,6 +985,33 @@ class Selection(GenericText, SelectionMixin, PopdownMixin): return False +class MultiSelection(GenericText, SelectionMixin): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.init_selection() + + @realized + @CellCache.cache + def setter(self, column, cell, store, iter_, user_data=None): + super().setter(column, cell, store, iter_, user_data=user_data) + cell.set_property('editable', False) + + def value_from_text(self, record, text, callback=None): + if callback: + callback() + + def get_textual_value(self, record): + field = record[self.attrs['name']] + self.update_selection(record, field) + selection = dict(self.selection) + values = [] + for value in field.get_eval(record): + text = selection.get(value, '') + values.append(text) + return ';'.join(values) + + class Reference(M2O): def __init__(self, view, attrs, renderer=None): diff --git a/tryton/gui/window/view_form/view/screen_container.py b/tryton/gui/window/view_form/view/screen_container.py index 9b107fa7fc..7769413eb4 100644 --- a/tryton/gui/window/view_form/view/screen_container.py +++ b/tryton/gui/window/view_form/view/screen_container.py @@ -604,7 +604,7 @@ class ScreenContainer(object): selections = (_('True'), _('False')) for selection in selections: entry.append_text(selection) - elif field['type'] == 'selection': + elif field['type'] in ['selection', 'multiselection']: selections = tuple(x[1] for x in field['selection']) entry = Selection(selections) entry.set_vexpand(True)