Read xxx2Many fields using the dotted notation

Closes #12856
This commit is contained in:
Nicolas Évrard
2024-04-07 17:39:13 +02:00
parent b973f371c8
commit 80ffcdea53
16 changed files with 349 additions and 28 deletions
+1
View File
@@ -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
+78 -14
View File
@@ -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];
+2 -2
View File
@@ -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]);
}
+6 -1
View File
@@ -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(
+1 -1
View File
@@ -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(() => {
+1
View File
@@ -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
@@ -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):
@@ -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
@@ -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:
@@ -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)
+3
View File
@@ -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
+8
View File
@@ -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 <common-attributes-yexpand>`,
:ref:`yfill <common-attributes-yfill>`,
:ref:`xexpand <common-attributes-xexpand>`,
+1
View File
@@ -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" }?
+11
View File
@@ -577,6 +577,17 @@
</attribute>
</optional>
</define>
<define name="attlist.field" combine="interleave">
<optional>
<attribute>
<name ns="">loading</name>
<choice>
<value>lazy</value>
<value>eager</value>
</choice>
</attribute>
</optional>
</define>
<define name="image">
<element>
<name ns="">image</name>
+21 -1
View File
@@ -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'):
+157
View File
@@ -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 = """
<form>
<group>
<field name="OK"/>
</group>
<group expandable="1">
<field name="OK"/>
<group expandable="0">
<field name="KO"/>
</group>
</group>
<group expandable="0">
<field name="KO"/>
<group>
<field name="KO"/>
</group>
</group>
<group>
<group>
<field name="OK"/>
</group>
<group expandable="1">
<field name="OK"/>
</group>
<group expandable="0">
<field name="KO"/>
</group>
</group>
</form>
"""
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 = """
<form>
<notebook>
<page>
<field name="OK"/>
<field name="OK"/>
<notebook>
<page>
<field name="OK"/>
<notebook>
<page>
<field name="OK"/>
<field name="OK"/>
</page>
<page>
<field name="KO"/>
</page>
</notebook>
</page>
<page>
<field name="KO"/>
</page>
</notebook>
</page>
<page>
<field name="KO"/>
<notebook>
<page>
<field name="KO"/>
</page>
<page>
<field name="KO"/>
</page>
</notebook>
</page>
</notebook>
</form>
"""
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 = """
<form>
<notebook>
<page>
<group>
<field name="OK"/>
</group>
<group expandable="0">
<field name="KO"/>
</group>
<group expandable="1">
<field name="OK"/>
</group>
</page>
<page>
<group>
<field name="KO"/>
</group>
<group expandable="0">
<field name="KO"/>
</group>
<group expandable="1">
<field name="KO"/>
</group>
</page>
</notebook>
<group expandable="0">
<notebook>
<page>
<field name="KO"/>
</page>
<page>
<field name="KO"/>
</page>
</notebook>
</group>
<group expandable="1">
<notebook>
<page>
<field name="OK"/>
</page>
<page>
<field name="KO"/>
</page>
</notebook>
</group>
<group>
<notebook>
<page>
<field name="OK"/>
</page>
<page>
<field name="KO"/>
</page>
</notebook>
</group>
</form>
"""
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"