diff --git a/sao/CHANGELOG b/sao/CHANGELOG index 74d8d89d7e..2baff8d5e1 100644 --- a/sao/CHANGELOG +++ b/sao/CHANGELOG @@ -1,3 +1,4 @@ +* Read xxx2Many fields using the dotted notation * Display XML ID on Log window * Use operators when converting PYSON to string * Add DnD on binary and image widgets diff --git a/sao/src/model.js b/sao/src/model.js index d60d87c9c2..f665db67c1 100644 --- a/sao/src/model.js +++ b/sao/src/model.js @@ -3,6 +3,39 @@ (function() { 'use strict'; + function get_x2m_sub_fields(f_attrs, prefix) { + if (f_attrs.loading == 'eager' && f_attrs.views) { + // There's only one key but we don't know its value + const [[, view],] = Object.entries(f_attrs.views); + + const sub_fields = view.fields || {}; + const x2m_sub_fields = []; + + for (const [s_field, f_def] of Object.entries(sub_fields)) { + x2m_sub_fields.push(`${prefix}.${s_field}`); + + var type_ = f_def.type; + if (['many2one', 'one2one', 'reference'].includes(type_)) { + x2m_sub_fields.push(`${prefix}.${s_field}.rec_name`); + } else if (['selection', 'multiselection'].includes(type_)) { + x2m_sub_fields.push(`${prefix}.${s_field}:string`); + } else if (['one2many', 'many2many'].includes(type_)) { + x2m_sub_fields.push( + ...get_x2m_sub_fields(f_def, `${prefix}.${s_field}`) + ); + } + } + + x2m_sub_fields.push( + `${prefix}._timestamp`, + `${prefix}._write`, + `${prefix}._delete`); + return x2m_sub_fields; + } else { + return []; + } + } + Sao.Model = Sao.class_(Object, { init: function(name, attributes) { attributes = attributes || {}; @@ -78,7 +111,7 @@ this.__readonly = value; } }); - array.load = function(ids, modified=false, position=-1) { + array.load = function(ids, modified=false, position=-1, preloaded=null) { if (position == -1) { position = this.length; } @@ -91,6 +124,9 @@ this.splice(position, 0, new_record); position += 1; } + if (preloaded && (id in preloaded)) { + new_record.set(preloaded[id], false, false); + } new_records.push(new_record); } // Remove previously removed or deleted records @@ -666,6 +702,7 @@ fnames.push(fname); } } + var related_read_limit = null; var fnames_to_fetch = fnames.slice(); var rec_named_fields = ['many2one', 'one2one', 'reference']; const selection_fields = ['selection', 'multiselection']; @@ -676,6 +713,13 @@ else if (~selection_fields.indexOf(fdescription.type) && ((fdescription.loading || 'lazy') == 'eager')) { fnames_to_fetch.push(fname + ':string'); + } else if ( + ['many2many', 'one2many'].includes(fdescription.type)) { + var sub_fields = get_x2m_sub_fields(fdescription, fname); + fnames_to_fetch = [ ...fnames_to_fetch, ...sub_fields]; + if (sub_fields.length > 0) { + related_read_limit = Sao.config.display_size; + } } } if (!~fnames.indexOf('rec_name')) { @@ -686,6 +730,9 @@ fnames_to_fetch.push('_delete'); var context = jQuery.extend({}, this.get_context()); + if (related_read_limit) { + context.related_read_limit = related_read_limit; + } if (loading == 'eager') { var limit = Math.trunc(Sao.config.limit / Math.min(fnames_to_fetch.length, 10)); @@ -845,7 +892,7 @@ } for (name in later) { value = later[name]; - this.model.fields[name].set(this, value); + this.model.fields[name].set(this, value, false, values[`${name}.`]); this._loaded[name] = true; fieldnames.push(name); } @@ -2183,7 +2230,7 @@ }, _default: null, _single_value: false, - _set_value: function(record, value, default_, modified) { + _set_value: function(record, value, default_, modified, data) { this._set_default_value(record); var group = record._values[this.name]; if (jQuery.isEmptyObject(value)) { @@ -2196,17 +2243,30 @@ } else { mode = 'list values'; } - if (mode == 'list values') { + if ((mode == 'list values') || data) { var context = this.get_context(record); - let field_names = new Set(); - for (const val of value) { - for (const fieldname in val) { - if (!(fieldname in group.model.fields) && - (!~fieldname.indexOf('.')) && - (!fieldname.startsWith('_'))) { - field_names.add(fieldname); + var value_fields = new Set(); + if (mode == 'list values') { + for (const v of value) { + for (const f of Object.keys(v)) { + value_fields.add(f); } } + } else { + for (const d of data) { + for (const f in d) { + value_fields.add(f); + } + } + } + let field_names = new Set(); + for (const fieldname of value_fields) { + if (!(fieldname in group.model.fields) && + (!~fieldname.indexOf('.')) && + (!~fieldname.indexOf(':')) && + (!fieldname.startsWith('_'))) { + field_names.add(fieldname); + } } var attr_fields = Object.values(this.description.views || {}) .map(v => v.fields) @@ -2254,7 +2314,11 @@ for (const record_to_remove of records_to_remove) { group.remove(record_to_remove, true, false, false); } - group.load(value, modified || default_); + var preloaded = {}; + for (const d of (data || [])) { + preloaded[d.id] = d; + } + group.load(value, modified || default_, -1, preloaded); } else { for (const vals of value) { var new_record; @@ -2279,7 +2343,7 @@ group.record_modified(); } }, - set: function(record, value, _default=false) { + set: function(record, value, _default=false, data=null) { var group = record._values[this.name]; var model; if (group !== undefined) { @@ -2292,7 +2356,7 @@ } record._values[this.name] = undefined; this._set_default_value(record, model); - this._set_value(record, value, _default); + this._set_value(record, value, _default, undefined, data); }, get: function(record) { var group = record._values[this.name]; diff --git a/sao/src/screen.js b/sao/src/screen.js index 072573e326..c5fd89dd68 100644 --- a/sao/src/screen.js +++ b/sao/src/screen.js @@ -1319,7 +1319,7 @@ } }, load: function(ids, set_cursor=true, modified=false, position=-1) { - this.group.load(ids, modified, position); + this.group.load(ids, modified, position, null); this.current_view.reset(); this.current_record = null; return this.display(set_cursor); @@ -1707,7 +1707,7 @@ var records = this.current_view.selected_records; this.model.copy(records, this.context) .then(new_ids => { - this.group.load(new_ids, false, this.new_position); + this.group.load(new_ids, false, this.new_position, null); if (!jQuery.isEmptyObject(new_ids)) { this.current_record = this.group.get(new_ids[0]); } diff --git a/sao/src/view/form.js b/sao/src/view/form.js index 4cbf60192f..e222efaf3a 100644 --- a/sao/src/view/form.js +++ b/sao/src/view/form.js @@ -58,6 +58,11 @@ function eval_pyson(value){ this.container.add(null, attributes); return; } + + if (attributes.loading == 'eager') { + this.field_attrs[name].loading = 'eager'; + } + var WidgetFactory = Sao.View.FormXMLViewParser.WIDGETS[ attributes.widget]; var widget = new WidgetFactory(this.view, attributes); @@ -3532,7 +3537,7 @@ function eval_pyson(value){ for (i = 0, len = result.length; i < len; i++) { ids.push(result[i][0]); } - this.screen.group.load(ids, true); + this.screen.group.load(ids, true, -1, null); prm = this.screen.display(); if (sequence) { this.screen.group.set_sequence( diff --git a/sao/src/window.js b/sao/src/window.js index 23d6e9c761..080d3ad4f5 100644 --- a/sao/src/window.js +++ b/sao/src/window.js @@ -445,7 +445,7 @@ for (const record of result) { ids.push(record[0]); } - this.screen.group.load(ids, true); + this.screen.group.load(ids, true, -1, null); prm = this.screen.display(); } prm.done(() => { diff --git a/tryton/CHANGELOG b/tryton/CHANGELOG index 09475654d1..2d68c619b0 100644 --- a/tryton/CHANGELOG +++ b/tryton/CHANGELOG @@ -1,3 +1,4 @@ +* Read xxx2Many fields using the dotted notation * Display XML ID on Log window * Use operators when converting PYSON to string * Add contextual menu to copy cell and column diff --git a/tryton/tryton/gui/window/view_form/model/field.py b/tryton/tryton/gui/window/view_form/model/field.py index 767eb30b51..29fab3064d 100644 --- a/tryton/tryton/gui/window/view_form/model/field.py +++ b/tryton/tryton/gui/window/view_form/model/field.py @@ -701,7 +701,8 @@ class O2MField(Field): skip={self.attrs.get('relation_field', '')})) return result - def _set_value(self, record, value, default=False, modified=False): + def _set_value( + self, record, value, default=False, modified=False, data=None): self._set_default_value(record) group = record.value[self.name] if value is None: @@ -711,13 +712,18 @@ class O2MField(Field): else: mode = 'list values' - if mode == 'list values': + if mode == 'list values' or data: context = self.get_context(record) - field_names = set(f for v in value for f in v + if mode == 'list values': + fields = set(f for v in value for f in v) + else: + fields = functools.reduce( + operator.or_, (d.keys() for d in data), set()) + field_names = {f for f in fields if (f not in group.fields and '.' not in f and ':' not in f - and not f.startswith('_'))) + and not f.startswith('_'))} attr_fields = functools.reduce( operator.or_, (v['fields'] for v in self.attrs.get('views', {}).values()), @@ -739,7 +745,9 @@ class O2MField(Field): records_to_remove = [r for r in group if r.id not in value] for record_to_remove in records_to_remove: group.remove(record_to_remove, remove=True, modified=False) - group.load(value, modified=modified or default) + group.load( + value, modified=modified or default, + preloaded={v['id']: v for v in (data or [])}) else: for vals in value: if 'id' in vals: @@ -760,7 +768,7 @@ class O2MField(Field): # Trigger modified only once group.record_modified() - def set(self, record, value, _default=False): + def set(self, record, value, _default=False, preloaded=None): group = record.value.get(self.name) fields = {} if group is not None: @@ -780,7 +788,7 @@ class O2MField(Field): # Prevent to trigger group-cleared group.parent = None - self._set_value(record, value, default=_default) + self._set_value(record, value, default=_default, data=preloaded) group.parent = record def set_client(self, record, value, force_change=False): diff --git a/tryton/tryton/gui/window/view_form/model/group.py b/tryton/tryton/gui/window/view_form/model/group.py index 60c1244180..3bf5b569aa 100644 --- a/tryton/tryton/gui/window/view_form/model/group.py +++ b/tryton/tryton/gui/window/view_form/model/group.py @@ -239,7 +239,7 @@ class Group(list): return [] return list({}.fromkeys(res)) - def load(self, ids, modified=False, position=-1): + def load(self, ids, modified=False, position=-1, preloaded=None): if not ids: return True @@ -256,6 +256,8 @@ class Group(list): else: self.insert(position, new_record) position += 1 + if preloaded and (already_loaded := preloaded.get(id)): + new_record.set(already_loaded, modified=False, validate=False) new_records.append(new_record) # Remove previously removed or deleted records diff --git a/tryton/tryton/gui/window/view_form/model/record.py b/tryton/tryton/gui/window/view_form/model/record.py index 870bead16f..40e12ebab8 100644 --- a/tryton/tryton/gui/window/view_form/model/record.py +++ b/tryton/tryton/gui/window/view_form/model/record.py @@ -1,6 +1,8 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. +import functools import logging +import operator import tryton.common as common from tryton.common import RPCException, RPCExecute @@ -13,6 +15,30 @@ from . import field as fields logger = logging.getLogger(__name__) +def get_x2m_sub_fields(f_attrs, prefix): + if f_attrs['loading'] == 'eager' and f_attrs.get('views'): + sub_fields = functools.reduce( + operator.or_, + (v.get('fields', {}) for v in f_attrs['views'].values()), + {}) + x2m_sub_fields = [] + for s_field, f_def in sub_fields.items(): + x2m_sub_fields.append(f"{prefix}.{s_field}") + if f_def['type'] in {'many2one', 'one2one', 'reference'}: + x2m_sub_fields.append(f"{prefix}.{s_field}.rec_name") + elif f_def['type'] in {'selection', 'multiselection'}: + x2m_sub_fields.append(f"{prefix}.{s_field}:string") + elif f_def['type'] in {'one2many', 'many2many'}: + x2m_sub_fields.extend( + get_x2m_sub_fields(f_def, f"{prefix}.{s_field}")) + x2m_sub_fields.extend( + f"{prefix}.{f}" + for f in ['_timestamp', '_write', '_delete']) + return x2m_sub_fields + else: + return [] + + class Record: id = -1 @@ -80,6 +106,7 @@ class Record: fnames = [fname for fname, field in fields if fname not in self._loaded and (not views or (views & field.views))] + related_read_limit = 0 for fname in list(fnames): f_attrs = self.group.fields[fname].attrs if f_attrs['type'] in {'many2one', 'one2one', 'reference'}: @@ -87,6 +114,11 @@ class Record: elif (f_attrs['type'] in {'selection', 'multiselection'} and f_attrs.get('loading', 'lazy') == 'eager'): fnames.append('%s:string' % fname) + elif f_attrs['type'] in {'many2many', 'one2many'}: + sub_fields = get_x2m_sub_fields(f_attrs, fname) + fnames.extend(sub_fields) + if sub_fields: + related_read_limit += len(sub_fields) if 'rec_name' not in fnames: fnames.append('rec_name') fnames.extend(['_timestamp', '_write', '_delete']) @@ -133,6 +165,9 @@ class Record: ctx.update(dict(('%s.%s' % (self.model_name, fname), 'size') for fname, field in self.group.fields.items() if field.attrs['type'] == 'binary' and fname in fnames)) + if related_read_limit: + ctx['related_read_limit'] = ( + CONFIG['client.limit'] // min(related_read_limit, 10)) exception = False try: values = RPCExecute('model', self.model_name, 'read', @@ -497,7 +532,9 @@ class Record: self._loaded.add(fieldname) fieldnames.append(fieldname) for fieldname, value in later.items(): - self.group.fields[fieldname].set(self, value) + if isinstance( + field := self.group.fields[fieldname], fields.O2MField): + field.set(self, value, preloaded=val.get(f"{fieldname}.")) self._loaded.add(fieldname) fieldnames.append(fieldname) if validate: diff --git a/tryton/tryton/gui/window/view_form/view/form.py b/tryton/tryton/gui/window/view_form/view/form.py index 50ba80b8a1..f3962fe577 100644 --- a/tryton/tryton/gui/window/view_form/view/form.py +++ b/tryton/tryton/gui/window/view_form/view/form.py @@ -230,6 +230,9 @@ class FormXMLViewParser(XMLViewParser): self.container.add(None, attributes) return + if attributes.get('loading') == 'eager': + self.field_attrs[name]['loading'] = 'eager' + widget = self.WIDGETS[attributes['widget']](self.view, attributes) self.view.widgets[name].append(widget) diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG index 99f7072c8c..beb8d63222 100644 --- a/trytond/CHANGELOG +++ b/trytond/CHANGELOG @@ -1,3 +1,6 @@ +* Set loading eager to visible xxx2many on form view +* Add loading attribute to form view +* Add a contextual read limit on Model.read * Add a contextual read limit for related fields * Add xml_id field on ModelStorage * Add send_message methods diff --git a/trytond/doc/topics/views/index.rst b/trytond/doc/topics/views/index.rst index d0913e6ec4..5499337ce1 100644 --- a/trytond/doc/topics/views/index.rst +++ b/trytond/doc/topics/views/index.rst @@ -365,6 +365,14 @@ attributes: ``rounded`` or ``circle``. The default value is ``square``. +``loading`` + Override the :attr:`~trytond.model.fields.Field.loading` of the field. + When ``eager`` for :class:`~trytond.model.fields.One2Many` and + :class:`~trytond.model.fields.Many2Many` the client may try to read the + fields of the related model using dotted notation. + This result in less round trip between the client and the server at the + expense of a bigger payload and a bigger query execution time. + :ref:`yexpand `, :ref:`yfill `, :ref:`xexpand `, diff --git a/trytond/trytond/ir/ui/form.rnc b/trytond/trytond/ir/ui/form.rnc index 9ad645b0dd..82e04caebd 100644 --- a/trytond/trytond/ir/ui/form.rnc +++ b/trytond/trytond/ir/ui/form.rnc @@ -121,6 +121,7 @@ attlist.field &= attlist.field &= attribute symbol { text }? attlist.field &= [a:defaultValue = "1"] attribute grouping { "0" | "1" }? attlist.field &= [a:defaultValue = "square"] attribute border { "square" | "circle" | "rounded" }? +attlist.field &= attribute loading { "lazy" | "eager" }? image = element image { attlist.image, empty } attlist.image &= attribute name { text } attlist.image &= [a:defaultValue = "icon"] attribute type { "icon" | "url" }? diff --git a/trytond/trytond/ir/ui/form.rng b/trytond/trytond/ir/ui/form.rng index 375e807e58..aca91af5cf 100644 --- a/trytond/trytond/ir/ui/form.rng +++ b/trytond/trytond/ir/ui/form.rng @@ -577,6 +577,17 @@ + + + + loading + + lazy + eager + + + + image diff --git a/trytond/trytond/model/modelview.py b/trytond/trytond/model/modelview.py index 0b778c2e80..e86b32c0b8 100644 --- a/trytond/trytond/model/modelview.py +++ b/trytond/trytond/model/modelview.py @@ -45,6 +45,23 @@ class ButtonActionException(RPCReturnException): return self.value +def set_visible(node, fields, loading='eager'): + "Set loading to visible fields" + if node.tag == 'group': + if node.attrib.get('expandable', '1') == '1': + for child in node: + set_visible(child, fields, loading) + elif node.tag == 'notebook': + if len(node) > 0: + set_visible(node[0], fields, loading) + elif node.tag == 'field': + if node.attrib['name'] in fields: + node.attrib['loading'] = 'eager' + else: + for child in node: + set_visible(child, fields, loading) + + def on_change(func): @wraps(func) def wrapper(self, *args, **kwargs): @@ -443,11 +460,14 @@ class ModelView(Model): if colspan is not None: element.attrib['colspan'] = colspan - # Remove empty pages if type == 'form': for page in tree.xpath('//page[not(descendant::*)]'): page.getparent().remove(page) + set_visible(tree, + {fname for fname, field in cls._fields.items() + if field._type in {'one2many', 'many2many'}}) + if type == 'tree': user = Transaction().user if Transaction().context.get('view_tree_width'): diff --git a/trytond/trytond/tests/test_modelview.py b/trytond/trytond/tests/test_modelview.py index 164f34e175..3417380a4f 100644 --- a/trytond/trytond/tests/test_modelview.py +++ b/trytond/trytond/tests/test_modelview.py @@ -8,6 +8,7 @@ from lxml import etree from trytond.model.exceptions import ( AccessButtonError, AccessError, ButtonActionException) +from trytond.model.modelview import set_visible from trytond.pool import Pool from trytond.pyson import Eval, PYSONDecoder, PYSONEncoder from trytond.tests.test_tryton import activate_module, with_transaction @@ -20,6 +21,162 @@ class ModelView(unittest.TestCase): def setUpClass(cls): activate_module('tests') + def test_set_visible(self): + "Test loading of visible x2m fields in group" + + GROUP_XML = """ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ """ + group_tree = etree.fromstring(GROUP_XML) + set_visible(group_tree, {'OK', 'KO'}) + self.assertEqual( + ['OK', 'OK', 'OK', 'OK'], + [n.attrib['name'] + for n in group_tree.xpath("//field[@loading='eager']")]) + + def test_mark_visible_x2m_notebook(self): + "Test marking visible x2m fields in notebook" + + NOTEBOOK_XML = """ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ """ + nb_tree = etree.fromstring(NOTEBOOK_XML) + set_visible(nb_tree, {'OK', 'KO'}) + self.assertEqual( + ['OK', 'OK', 'OK', 'OK', 'OK'], + [n.attrib['name'] + for n in nb_tree.xpath("//field[@loading='eager']")]) + + def test_mark_visible_x2m_notebook_group(self): + "Test marking visible x2m fields in notebook and groups" + + NBGROUP_XML = """ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ """ + ng_tree = etree.fromstring(NBGROUP_XML) + set_visible(ng_tree, {'OK', 'KO'}) + self.assertEqual( + ['OK', 'OK', 'OK', 'OK'], + [n.attrib['name'] + for n in ng_tree.xpath("//field[@loading='eager']")]) + @with_transaction() def test_changed_values(self): "Test ModelView._changed_values"