workflows: allow create formdata & related actions to set a subfield (#77522) #892
|
@ -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 = [
|
||||
|
||||
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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
fpeters
commented
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)
fpeters
commented
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):
|
||||
|
|
|
@ -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():
|
||||
fpeters
commented
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
|
||||
fpeters
commented
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
|
||||
fpeters
commented
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
|
||||
fpeters
commented
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={}
|
||||
)
|
||||
fpeters
commented
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(
|
||||
fpeters
commented
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')
|
||||
|
|
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.