workflows: allow create formdata & related actions to set a subfield (#77522) #892

Merged
fpeters merged 1 commits from wip/77522-create-formdata-set-block-subfield into main 2023-12-18 13:19:55 +01:00
4 changed files with 263 additions and 12 deletions

View File

@ -16,7 +16,8 @@ from wcs.wf.create_formdata import JournalAssignationErrorPart, Mapping
from wcs.workflow_traces import WorkflowTrace
from wcs.workflows import Workflow
from ..utilities import clean_temporary_pub, create_temporary_pub
from ..utilities import clean_temporary_pub, create_temporary_pub, get_app, login
from .test_all import admin_user # noqa pylint: disable=unused-import
def setup_module(module):
@ -31,6 +32,7 @@ def teardown_module(module):
def pub(request):
pub = create_temporary_pub()
pub.cfg['language'] = {'language': 'en'}
pub.cfg['identification'] = {'methods': ['password']}
pub.write_cfg()
req = HTTPRequest(None, {'SERVER_NAME': 'example.net', 'SCRIPT_NAME': ''})
req.response.filter = {}
@ -613,6 +615,90 @@ def test_create_carddata_map_fields_by_varname(pub):
assert not carddata.data.get('4')
def test_create_carddata_partial_block_field(pub, admin_user):
BlockDef.wipe()
CardDef.wipe()
block = BlockDef()
block.name = 'foobar'
block.digest_template = 'X{{foobar_var_foo}}Y'
block.fields = [
StringField(id='123', required=True, label='Test', varname='foo'),
StringField(id='234', required=True, label='Test2', varname='bar'),
]
block.store()
carddef = CardDef()
carddef.name = 'Foo Card'
carddef.fields = [
StringField(id='0', label='foo', varname='foo'),
BlockField(id='1', label='block field', block_slug='foobar', varname='foobar'),
]
carddef.store()
carddef.data_class().wipe()
card_wf = Workflow(name='Card workflow')
st1 = card_wf.add_status('Status1')
st2 = card_wf.add_status('Status2')
choice = st1.add_action('choice', id='_x')
choice.status = 'wf-%s' % st2.id
create = st2.add_action('create_carddata', id='_create')
create.formdef_slug = carddef.url_name
create.mappings = [
Mapping(field_id='0', expression='new value'),
Mapping(field_id='1', expression='{{ form_var_foobar }}'),
Mapping(field_id='1$123', expression='new subfield value'),
]
card_wf.store()
carddef.workflow = card_wf
carddef.store()
# execute on card
carddata = carddef.data_class()()
carddata.data = {
'0': 'foo',
'1': {
'data': [{'123': 'foo', '234': 'bar'}],
'schema': {'123': 'string', '234': 'string'},
},
'1_display': 'XfooY',
}
carddata.store()
carddata.just_created()
carddata.jump_status(st2.id)
carddata.store()
pub.loggederror_class.wipe()
carddata.perform_workflow()
# check there were no errors
assert pub.loggederror_class.count() == 0
# check current carddata has not been changed
carddata.refresh_from_storage()
assert carddata.data == {
'0': 'foo',
'1': {
'data': [{'123': 'foo', '234': 'bar'}],
'schema': {'123': 'string', '234': 'string'},
},
'1_display': 'XfooY',
}
# check new carddata
assert carddef.data_class().count() == 2
new_carddata = carddef.data_class().select(order_by='-id')[0]
assert new_carddata.data == {
'0': 'new value',
'1': {
'data': [{'123': 'new subfield value', '234': 'bar'}],
'schema': {'123': 'string', '234': 'string'},
},
'1_display': 'Xnew subfield valueY',
}
def test_edit_carddata_with_data_sourced_object(pub):
FormDef.wipe()
CardDef.wipe()
@ -1134,6 +1220,121 @@ def test_edit_carddata_invalid_file_field(pub):
assert carddata.data == {'0': 'new value', '4': None}
def test_edit_carddata_partial_block_field(pub, admin_user):
BlockDef.wipe()
CardDef.wipe()
block = BlockDef()
block.name = 'foobar'
block.digest_template = 'X{{foobar_var_foo}}Y'
block.fields = [

Contrairement à ce que j'écris dans le ticket, en cas de plusieurs lignes, j'applique la modification sur toutes les lignes, ça me semble au final plus logique.

Contrairement à ce que j'écris dans le ticket, en cas de plusieurs lignes, j'applique la modification sur toutes les lignes, ça me semble au final plus logique.
StringField(id='123', required=True, label='Test', varname='foo'),
StringField(id='234', required=True, label='Test2', varname='bar'),
]
block.store()
carddef = CardDef()
carddef.name = 'Foo Card'
carddef.fields = [
StringField(id='0', label='foo', varname='foo'),
BlockField(id='1', label='block field', block_slug='foobar'),
]
carddef.store()
carddef.data_class().wipe()
card_wf = Workflow(name='Card workflow')
st1 = card_wf.add_status('Status1')
edit = st1.add_action('edit_carddata', id='_edit')
edit.formdef_slug = carddef.url_name
edit.target_mode = 'manual'
edit.target_id = '{{ form_internal_id }}' # itself
edit.mappings = [
Mapping(field_id='0', expression='new value'),
Mapping(field_id='1$123', expression='new subfield value'),
Mapping(field_id='1$234', expression=None),
]
card_wf.store()
carddef.workflow = card_wf
carddef.store()
# check action form
resp = login(get_app(pub), username='admin', password='admin').get(edit.get_admin_url())
assert resp.form['mappings$element1$field_id'].options == [
('', False, '---'),
('0', False, 'foo'),
('1', False, 'block field'),
('1$123', True, 'block field - Test'),
('1$234', False, 'block field - Test2'),
]
resp = resp.form.submit('submit')
# execute on card
carddata = carddef.data_class()()
carddata.data = {
'0': 'foo',
'1': {
'data': [{'123': 'foo', '234': 'bar'}],
'schema': {'123': 'string', '234': 'string'},
},
'1_display': 'XfooY',
}
carddata.store()
carddata.just_created()
carddata.store()
pub.loggederror_class.wipe()
carddata.perform_workflow()
assert carddata.data == {
'0': 'new value',
'1': {
'data': [{'123': 'new subfield value', '234': None}],
'schema': {'123': 'string', '234': 'string'},
},
'1_display': 'Xnew subfield valueY',
}
# execute on card with multiple block rows
carddata = carddef.data_class()()
carddata.data = {
'0': 'foo',
'1': {
'data': [{'123': 'foo', '234': 'bar'}, {'123': 'foo2', '234': 'bar2'}],
'schema': {'123': 'string', '234': 'string'},
},
'1_display': 'XfooY, Xfoo2Y',
}
carddata.store()
carddata.just_created()
carddata.store()
pub.loggederror_class.wipe()
carddata.perform_workflow()
assert carddata.data == {
'0': 'new value',
'1': {
'data': [{'123': 'new subfield value', '234': None}, {'123': 'new subfield value', '234': None}],
'schema': {'123': 'string', '234': 'string'},
},
'1_display': 'Xnew subfield valueY, Xnew subfield valueY',
}
# execute on card without any block data
carddata = carddef.data_class()()
carddata.data = {'0': 'foo'}
carddata.store()
carddata.just_created()
carddata.store()
pub.loggederror_class.wipe()
carddata.perform_workflow()
assert carddata.data == {
'0': 'new value',
'1': {
'data': [{'123': 'new subfield value', '234': None}],
'schema': {'123': 'string', '234': 'string'},
},
'1_display': 'Xnew subfield valueY',
}
def test_edit_carddata_invalid_block_field(pub):
BlockDef.wipe()
CardDef.wipe()

View File

@ -24,7 +24,7 @@ from contextlib import contextmanager
from quixote import get_publisher, get_request, get_response
from quixote.html import htmltag, htmltext
from . import data_sources
from . import data_sources, fields
from .categories import BlockCategory
from .formdata import FormData
from .qommon import _, misc
@ -132,6 +132,9 @@ class BlockDef(StorableObject):
def get_field_admin_url(self, field):
return self.get_admin_url() + '%s/' % field.id
def get_widget_fields(self):
return [field for field in self.fields or [] if isinstance(field, fields.WidgetField)]
def get_display_value(self, value):
if not self.digest_template:
return self.name

View File

@ -293,6 +293,8 @@ class BlockField(WidgetField):
value = value.make_value(block=self.block, field=self, data=data)
elif value and not (isinstance(value, dict) and 'data' in value and 'schema' in value):
raise SetValueError(_('invalid value for block (field id: %s)') % self.id)
elif value:
value = copy.deepcopy(value)

Dans une action de création de demande, avec d'abord une affectation de tout le bloc, puis d'un de ses champs, le dictionnaire avec les données du bloc était partagé et à l'enregistrement du formdata d'origine, sa valeur était modifiée.

(ce cas n'apparait pas sur le cas de test, oui clairement ça indique que je devrais écrire un test en plus)

Dans une action de création de demande, avec d'abord une affectation de tout le bloc, puis d'un de ses champs, le dictionnaire avec les données du bloc était partagé et à l'enregistrement du formdata d'origine, sa valeur était modifiée. (ce cas n'apparait pas sur le cas de test, oui clairement ça indique que je devrais écrire un test en plus)

J'ai maintenant ajouté test_create_carddata_partial_block_field pour vérifier ça.

J'ai maintenant ajouté test_create_carddata_partial_block_field pour vérifier ça.
super().set_value(data, value, **kwargs)
def get_json_value(self, value, **kwargs):

View File

@ -22,6 +22,7 @@ from django.utils.functional import cached_property
from quixote import get_publisher, get_request, get_session
from quixote.html import TemplateIO, htmltext
from wcs.fields.block import BlockRowValue
from wcs.formdef import FormDef
from wcs.qommon import _, ngettext, pgettext
from wcs.qommon.form import (
@ -73,9 +74,19 @@ class MappingWidget(CompositeWidget):
)
def _fields_to_options(self, formdef):
return [(None, '---', '')] + [
(field.id, field.label, str(field.id)) for field in formdef.get_widget_fields()
]
options = [(None, '---', '')]
for field in formdef.get_widget_fields():
options.append((field.id, field.label, str(field.id)))
if field.key == 'block':
for subfield in field.block.get_widget_fields():

La structure avec la double boucle se répète plusieurs fois dans le fichier, dans différentes variantes selon ce qui veut être construit (ici une liste, ailleurs un dictionnaire pointant les champs, encore ailleurs un dictionnaire pointant une position), j'ai trouvé qu'au final ça restait plus lisible de répéter un peu.

La structure avec la double boucle se répète plusieurs fois dans le fichier, dans différentes variantes selon ce qui veut être construit (ici une liste, ailleurs un dictionnaire pointant les champs, encore ailleurs un dictionnaire pointant une position), j'ai trouvé qu'au final ça restait plus lisible de répéter un peu.
options.append(
(
f'{field.id}${subfield.id}',
f'{field.label} - {subfield.label}',
f'{field.id}${subfield.id}',
)
)
return options
def _parse(self, request):
super()._parse(request)
@ -114,9 +125,16 @@ class MappingsWidget(WidgetListAsTable):
@cached_property
def ranks(self):
return {
str(field.id): i for i, field in enumerate(field for field in self.to_formdef.get_widget_fields())
}
ranks = {}
i = 0
for field in self.to_formdef.get_widget_fields():
ranks[str(field.id)] = i

Voilà le cas où on crée un dictionnaire avec une position.

Voilà le cas où on crée un dictionnaire avec une position.
i += 1
if field.key == 'block':
for subfield in field.block.get_widget_fields():
ranks[f'{field.id}${subfield.id}'] = i
i += 1
return ranks
def _parse(self, request):
super()._parse(request)
@ -536,9 +554,15 @@ class CreateFormdataWorkflowStatusItem(WorkflowStatusItem):
# keep a cache of field labels, to be used in case of errors if fields are removed
mapped_field_ids = [x.field_id for x in self.mappings or []]
if self.formdef:
self.cached_field_labels = {
x.id: x.label for x in self.formdef.get_widget_fields() if x.id in mapped_field_ids
}
self.cached_field_labels = {}
for field in self.formdef.get_widget_fields():
if field.id in mapped_field_ids:
self.cached_field_labels[field.id] = field.label

Et voilà le cas où on crée un dictionnaire de l'id au libellé, pour certains champs uniquement.

Et voilà le cas où on crée un dictionnaire de l'id au libellé, pour certains champs uniquement.
if field.key == 'block':
for subfield in field.block.get_widget_fields():
mapped_subfield_id = f'{field.id}${subfield.id}'
if mapped_subfield_id in mapped_field_ids:
self.cached_field_labels[mapped_subfield_id] = f'{field.label} - {subfield.label}'
def get_mappings_parameter_view_value(self):
to_id_fields = {str(field.id): field for field in self.formdef.get_widget_fields()}
@ -781,8 +805,17 @@ class CreateFormdataWorkflowStatusItem(WorkflowStatusItem):
# field.id can be serialized to xml, so we must always convert them to
# str when matching
to_id_fields = {str(field.id): field for field in self.formdef.get_widget_fields()}
for field in self.formdef.get_widget_fields():
if field.key == 'block':
for subfield in field.block.get_widget_fields():
to_id_fields[f'{field.id}${subfield.id}'] = subfield

Et ici un dictionnaire de l'id au champ, pour tous les champs.

Et ici un dictionnaire de l'id au champ, pour tous les champs.
subfield.parent_block_field = field
missing_fields = []
# sort mappings to be sure block subfields come after parent block fields
if self.mappings:
self.mappings.sort(key=lambda x: x.field_id)
for mapping in self.mappings or []:
try:
dest_field = to_id_fields[str(mapping.field_id)]
@ -830,7 +863,19 @@ class CreateFormdataWorkflowStatusItem(WorkflowStatusItem):
dummy = value # noqa: F841, copy value for debug
value = field.convert_value_from_anything(value)
field.set_value(formdata.data, value)
parent_block_field = getattr(field, 'parent_block_field', None)
if parent_block_field:
if not formdata.data.get(parent_block_field.id):
formdata.data[parent_block_field.id] = BlockRowValue().make_value(
block=parent_block_field.block, field=parent_block_field, data={}
)

BlockRowValue().make_value(...) va créer un dictionnaire {'data': [{}], 'schema': ...} dans lequel la nouvelle valeur pourra s'insérer.

BlockRowValue().make_value(...) va créer un dictionnaire {'data': [{}], 'schema': ...} dans lequel la nouvelle valeur pourra s'insérer.
for sub_data in formdata.data[parent_block_field.id]['data']:
field.set_value(sub_data, value)
formdata.data[f'{parent_block_field.id}_display'] = parent_block_field.store_display_value(

Après avoir modifié la valeur c'est important d'enregistre le nouveau rendu "_display".

Après avoir modifié la valeur c'est important d'enregistre le nouveau rendu "_display".
formdata.data, parent_block_field.id
)
else:
field.set_value(formdata.data, value)
def mappings_export_to_xml(self, parent, charset, include_id=False):
container = ET.SubElement(parent, 'mappings')