From 970f3b10ec7f6df240fa3221169bfb910ff0aa1b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Thu, 25 Nov 2021 15:30:28 +0100
Subject: [PATCH] wf: add assign carddata action (#57962)

---
 tests/admin_pages/test_workflow.py |  94 +++++
 tests/workflow/test_carddata.py    | 610 ++++++++++++++++++++++++++++-
 wcs/wf/assign_carddata.py          |  71 ++++
 wcs/wf/create_formdata.py          |  40 +-
 wcs/wf/external_workflow.py        |   1 +
 wcs/workflows.py                   |   2 +
 6 files changed, 799 insertions(+), 19 deletions(-)
 create mode 100644 wcs/wf/assign_carddata.py

diff --git a/tests/admin_pages/test_workflow.py b/tests/admin_pages/test_workflow.py
index 8dd8bf5f..a27e3b75 100644
--- a/tests/admin_pages/test_workflow.py
+++ b/tests/admin_pages/test_workflow.py
@@ -16,6 +16,7 @@ from wcs.mail_templates import MailTemplate
 from wcs.qommon.errors import ConnectionError
 from wcs.qommon.form import UploadedFile
 from wcs.qommon.http_request import HTTPRequest
+from wcs.wf.assign_carddata import AssignCarddataWorkflowStatusItem
 from wcs.wf.create_carddata import CreateCarddataWorkflowStatusItem
 from wcs.wf.create_formdata import CreateFormdataWorkflowStatusItem, Mapping
 from wcs.wf.dispatch import DispatchWorkflowStatusItem
@@ -2508,6 +2509,99 @@ def test_workflows_edit_carddata_action(pub):
     assert Workflow.get(wf.id).possible_status[0].items[0].target_id == '{{ form_var_plop_id }}'
 
 
+def test_workflows_assign_carddata_action_options(pub):
+    create_superuser(pub)
+
+    Workflow.wipe()
+    wf = Workflow(name='assign card')
+    st = wf.add_status('New')
+    assign_carddata = AssignCarddataWorkflowStatusItem()
+    assign_carddata.parent = st
+    st.items.append(assign_carddata)
+    wf.store()
+
+    app = login(get_app(pub))
+
+    if not pub.site_options.has_section('options'):
+        pub.site_options.add_section('options')
+
+    app = login(get_app(pub))
+
+    for value in ['false', 'true']:
+        # no python expressions
+        pub.site_options.set('options', 'disable-python-expressions', value)
+        with open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w') as fd:
+            pub.site_options.write(fd)
+        resp = app.get('/backoffice/workflows/%s/status/%s/items/1/' % (wf.id, st.id))
+        assert resp.form['target_id$type'].options == [
+            ('text', False, 'Text'),
+            ('template', False, 'Template'),
+        ]
+        assert resp.form['user_association_template$type'].options == [
+            ('text', False, 'Text'),
+            ('template', False, 'Template'),
+        ]
+        assert resp.form['condition$type'].options == [
+            ('django', False, 'Django Expression'),
+        ]
+
+
+def test_workflows_assign_carddata_action(pub):
+    create_superuser(pub)
+    Workflow.wipe()
+    CardDef.wipe()
+
+    wf = Workflow(name='assign card')
+    st = wf.add_status('Update card', 'st')
+    wf.store()
+
+    carddef = CardDef()
+    carddef.name = 'My card'
+    carddef.fields = [
+        fields.StringField(id='1', label='string'),
+    ]
+    carddef.store()
+
+    app = login(get_app(pub))
+
+    resp = app.get('/backoffice/workflows/%s/status/%s/' % (wf.id, st.id))
+    assert 'Assign Card Data' in [o[0] for o in resp.forms[0]['action-formdata-action'].options]
+
+    resp.forms[0]['action-formdata-action'] = 'Assign Card Data'
+    resp = resp.forms[0].submit().follow()
+    assert 'Assign Card Data (not configured)' in resp.text
+
+    resp = app.get('/backoffice/workflows/%s/status/%s/items/1/' % (wf.id, st.id))
+    resp.forms[0]['formdef_slug'] = 'my-card'
+    resp = resp.forms[0].submit('submit')
+    assert 'Edit Card Data (not configured)' not in resp.text
+    assert Workflow.get(wf.id).possible_status[0].items[0].target_mode == 'all'
+    assert Workflow.get(wf.id).possible_status[0].items[0].target_id is None
+    assert Workflow.get(wf.id).possible_status[0].items[0].user_association_mode is None
+    assert Workflow.get(wf.id).possible_status[0].items[0].user_association_template is None
+
+    resp = app.get('/backoffice/workflows/%s/status/%s/items/1/' % (wf.id, st.id))
+    resp.forms[0]['target_mode'] = 'manual'
+    resp.forms[0]['target_id$type'] = 'template'
+    resp.forms[0]['target_id$value_template'] = '{{ form_var_plop_id }}'
+    resp.forms[0]['user_association_mode'] = 'keep-user'
+    resp = resp.forms[0].submit('submit')
+    assert Workflow.get(wf.id).possible_status[0].items[0].target_mode == 'manual'
+    assert Workflow.get(wf.id).possible_status[0].items[0].target_id == '{{ form_var_plop_id }}'
+    assert Workflow.get(wf.id).possible_status[0].items[0].user_association_mode == 'keep-user'
+    assert Workflow.get(wf.id).possible_status[0].items[0].user_association_template is None
+
+    resp = app.get('/backoffice/workflows/%s/status/%s/items/1/' % (wf.id, st.id))
+    resp.forms[0]['user_association_mode'] = 'custom'
+    resp.forms[0]['user_association_template$type'] = 'template'
+    resp.forms[0]['user_association_template$value_template'] = '{{ form_var_user_id }}'
+    resp = resp.forms[0].submit('submit')
+    assert Workflow.get(wf.id).possible_status[0].items[0].user_association_mode == 'custom'
+    assert (
+        Workflow.get(wf.id).possible_status[0].items[0].user_association_template == '{{ form_var_user_id }}'
+    )
+
+
 def test_workflows_criticality_levels(pub):
     create_superuser(pub)
 
diff --git a/tests/workflow/test_carddata.py b/tests/workflow/test_carddata.py
index 75fab82c..7ca80131 100644
--- a/tests/workflow/test_carddata.py
+++ b/tests/workflow/test_carddata.py
@@ -10,6 +10,7 @@ from wcs.fields import DateField, FileField, ItemField, MapField, StringField
 from wcs.formdef import FormDef
 from wcs.qommon.http_request import HTTPRequest
 from wcs.qommon.upload_storage import PicklableUpload
+from wcs.wf.assign_carddata import AssignCarddataWorkflowStatusItem
 from wcs.wf.create_carddata import CreateCarddataWorkflowStatusItem
 from wcs.wf.create_formdata import CreateFormdataWorkflowStatusItem, Mapping
 from wcs.wf.edit_carddata import EditCarddataWorkflowStatusItem
@@ -435,6 +436,8 @@ def test_create_carddata_user_association(two_pubs):
     user.email = 'test@example.net'
     user.name_identifiers = ['xyz']
     user.store()
+    user2 = two_pubs.user_class()
+    user2.store()
 
     carddef = CardDef()
     carddef.name = 'My card'
@@ -507,7 +510,7 @@ def test_create_carddata_user_association(two_pubs):
 
     formdata = FormDef.get(formdef.id).data_class()()
     formdata.data = {}
-    formdata.user_id = user.id
+    formdata.user_id = user2.id
     formdata.just_created()
     formdata.perform_workflow()
 
@@ -522,7 +525,7 @@ def test_create_carddata_user_association(two_pubs):
 
     formdata = FormDef.get(formdef.id).data_class()()
     formdata.data = {}
-    formdata.user_id = user.id
+    formdata.user_id = user2.id
     formdata.just_created()
     formdata.perform_workflow()
 
@@ -549,7 +552,7 @@ def test_create_carddata_user_association(two_pubs):
         assert two_pubs.loggederror_class.count() == 1
         logged_error = two_pubs.loggederror_class.select()[0]
         assert logged_error.summary == 'Failed to attach user (not found: "zzz")'
-        assert logged_error.formdata_id == str(carddef.data_class().select()[0].id)
+        assert logged_error.formdata_id == str(formdata.id)
 
     # user association on invalid template
     carddef.data_class().wipe()
@@ -590,6 +593,7 @@ def test_edit_carddata_with_data_sourced_object(pub):
     ]
     carddef.digest_templates = {'default': '{{ form_var_first_name }} {{ form_var_last_name }}'}
     carddef.store()
+    carddef.data_class().wipe()
 
     carddata = carddef.data_class()()
     carddata.data = {'0': 'Foo', '1': 'Bar', '2': 'l'}
@@ -677,6 +681,7 @@ def test_edit_carddata_with_linked_object(pub):
         StringField(id='2', label='Kids number', varname='kids_number'),
     ]
     carddef.store()
+    carddef.data_class().wipe()
 
     wf = Workflow(name='Card create and update')
     st1 = wf.add_status('Create card', 'st1')
@@ -1039,3 +1044,602 @@ def test_edit_carddata_from_created_object(pub):
     carddata_reloaded = carddata.get(carddata.id)
     assert carddata_reloaded.data['0'] == 'HELLO'
     assert carddata_reloaded.status == 'wf-2'
+
+
+def test_assign_carddata_with_data_sourced_object(pub):
+    FormDef.wipe()
+    CardDef.wipe()
+    pub.user_class.wipe()
+
+    user = pub.user_class()
+    user.email = 'test@example.net'
+    user.name_identifiers = ['xyz']
+    user.store()
+
+    carddef = CardDef()
+    carddef.name = 'Person'
+    carddef.fields = [
+        StringField(id='0', label='First Name', varname='first_name'),
+        StringField(id='1', label='Last Name', varname='last_name'),
+    ]
+    carddef.digest_templates = {'default': '{{ form_var_first_name }} {{ form_var_last_name }}'}
+    carddef.store()
+    carddef.data_class().wipe()
+
+    carddata = carddef.data_class()()
+    carddata.data = {'0': 'Foo', '1': 'Bar'}
+    carddata.store()
+
+    wf = Workflow(name='Card update')
+    st1 = wf.add_status('Assign card', 'st1')
+
+    assign = AssignCarddataWorkflowStatusItem()
+    assign.formdef_slug = carddef.url_name
+    assign.user_association_mode = 'keep-user'
+    assign.id = 'assign'
+    st1.items.append(assign)
+    assign.parent = st1
+    wf.store()
+
+    datasource = {'type': 'carddef:%s' % carddef.url_name}
+    formdef = FormDef()
+    formdef.name = 'Persons'
+    formdef.fields = [
+        ItemField(id='0', label='Person', type='item', varname='person', data_source=datasource),
+    ]
+    formdef.workflow = wf
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.data = {'0': '1'}
+    formdata.user_id = user.id
+    formdata.store()
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    data = carddef.data_class().select()[0]
+    assert data.user_id == user.id
+
+
+def test_assign_carddata_with_linked_object(pub):
+    FormDef.wipe()
+    CardDef.wipe()
+    pub.user_class.wipe()
+
+    user = pub.user_class()
+    user.email = 'test@example.net'
+    user.name_identifiers = ['xyz']
+    user.store()
+
+    carddef = CardDef()
+    carddef.name = 'Parent'
+    carddef.fields = [
+        StringField(id='0', label='First Name', varname='first_name'),
+        StringField(id='1', label='Last Name', varname='last_name'),
+    ]
+    carddef.store()
+    carddef.data_class().wipe()
+
+    wf = Workflow(name='Card create and assign')
+    st1 = wf.add_status('Create card', 'st1')
+    create = CreateCarddataWorkflowStatusItem()
+    create.formdef_slug = carddef.url_name
+    create.user_association_mode = None
+    create.mappings = [
+        Mapping(field_id='0', expression='{{ form_var_first_name }}'),
+        Mapping(field_id='1', expression='{{ form_var_last_name }}'),
+    ]
+    st1.items.append(create)
+    create.parent = st1
+    jump = JumpWorkflowStatusItem()
+    jump.id = '_jump'
+    jump.by = ['_submitter', '_receiver']
+    jump.status = 'st2'
+    st1.items.append(jump)
+    jump.parent = st1
+
+    st2 = wf.add_status('Assign card', 'st2')
+    assign = AssignCarddataWorkflowStatusItem()
+    assign.formdef_slug = carddef.url_name
+    assign.user_association_mode = 'keep-user'
+    assign.id = 'assign'
+    st2.items.append(assign)
+    assign.parent = st2
+    wf.store()
+
+    formdef = FormDef()
+    formdef.name = 'Parents'
+    formdef.fields = [
+        StringField(id='0', label='First Name', varname='first_name'),
+        StringField(id='1', label='Last Name', varname='last_name'),
+    ]
+    formdef.workflow = wf
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.data = {'0': 'Parent', '1': 'Foo'}
+    formdata.user_id = user.id
+    formdata.store()
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    assert carddef.data_class().count() == 1
+    card_data = carddef.data_class().select()[0]
+    assert card_data.user_id == user.id
+
+
+def test_assign_carddata_manual_targeting(two_pubs):
+    if not two_pubs.is_using_postgresql():
+        pytest.skip('this requires SQL')
+        return
+
+    FormDef.wipe()
+    CardDef.wipe()
+    two_pubs.loggederror_class.wipe()
+    two_pubs.user_class.wipe()
+
+    user = two_pubs.user_class()
+    user.email = 'test@example.net'
+    user.name_identifiers = ['xyz']
+    user.store()
+
+    # carddef
+    carddef = CardDef()
+    carddef.name = 'Parent'
+    carddef.fields = [
+        StringField(id='0', label='First Name', varname='first_name'),
+        StringField(id='1', label='Last Name', varname='last_name'),
+    ]
+    carddef.store()
+    carddef.data_class().wipe()
+
+    # and sample carddatas
+    for i in range(1, 4):
+        carddata = carddef.data_class()()
+        carddata.data = {
+            '0': 'First name %s' % i,
+            '1': 'Last name %s' % i,
+        }
+        carddata.store()
+
+    # formdef workflow that will assign carddata
+    wf = Workflow(name='Card create and Assign')
+    st1 = wf.add_status('Create card', 'st1')
+    # create linked carddata
+    create = CreateCarddataWorkflowStatusItem()
+    create.formdef_slug = carddef.url_name
+    create.user_association_mode = None
+    create.mappings = [
+        Mapping(field_id='0', expression='{{ form_var_first_name }}'),
+        Mapping(field_id='1', expression='{{ form_var_last_name }}'),
+    ]
+    st1.items.append(create)
+    create.parent = st1
+    jump = JumpWorkflowStatusItem()
+    jump.id = '_jump'
+    jump.by = ['_submitter', '_receiver']
+    jump.status = 'st2'
+    st1.items.append(jump)
+    jump.parent = st1
+
+    st2 = wf.add_status('Assign card', 'st2')
+    assign = AssignCarddataWorkflowStatusItem()
+    assign.formdef_slug = carddef.url_name
+    assign.target_mode = 'manual'  # not configured
+    assign.user_association_mode = 'keep-user'
+    assign.id = 'assign'
+    st2.items.append(assign)
+    assign.parent = st2
+    wf.store()
+
+    # associated formdef
+    formdef = FormDef()
+    formdef.name = 'Parents'
+    datasource = {'type': 'carddef:%s' % carddef.url_name}
+    formdef.fields = [
+        StringField(id='0', label='First Name', varname='first_name'),
+        StringField(id='1', label='Last Name', varname='last_name'),
+        StringField(id='2', label='string', varname='string'),
+        ItemField(id='3', label='Card', type='item', varname='card', data_source=datasource),
+    ]
+    formdef.workflow = wf
+    formdef.store()
+
+    # create formdatas
+
+    # target not configured
+    formdata = formdef.data_class()()
+    formdata.data = {
+        '0': 'Parent',
+        '1': 'Foo',
+        '2': '1',
+        '3': '3',  # set from datasource
+    }
+    # set parent
+    formdata.submission_context = {
+        'orig_object_type': 'carddef',
+        'orig_formdata_id': '2',
+        'orig_formdef_id': str(carddef.id),
+    }
+    formdata.user_id = user.id
+    formdata.store()
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    assert carddef.data_class().count() == 4
+    assert carddef.data_class().get(1).user_id is None
+    assert carddef.data_class().get(2).user_id is None
+    assert carddef.data_class().get(3).user_id is None
+    assert carddef.data_class().get(4).user_id is None
+    assert two_pubs.loggederror_class.count() == 0
+
+    # configure target
+    assign.target_id = '{{ form_var_string }}'  # == '1'
+    wf.store()
+    formdata = formdef.data_class()()
+    formdata.data = {
+        '0': 'Parent',
+        '1': 'Foo',
+        '2': '1',
+        '3': '3',  # set from datasource
+    }
+    # set parent
+    formdata.submission_context = {
+        'orig_object_type': 'carddef',
+        'orig_formdata_id': '2',
+        'orig_formdef_id': str(carddef.id),
+    }
+    formdata.user_id = user.id
+    formdata.store()
+    formdata.just_created()
+    formdata.perform_workflow()
+    assert carddef.data_class().count() == 5
+    assert carddef.data_class().get(1).user_id == str(user.id)
+    assert carddef.data_class().get(2).user_id is None
+    assert carddef.data_class().get(3).user_id is None
+    assert carddef.data_class().get(4).user_id is None
+    assert carddef.data_class().get(5).user_id is None
+    assert two_pubs.loggederror_class.count() == 0
+
+    # target not found
+    assign.target_id = '42{{ form_var_string }}'  # == '424'
+    wf.store()
+    formdata = formdef.data_class()()
+    formdata.data = {
+        '0': 'Parent',
+        '1': 'Foo',
+        '2': '4',
+        '3': '3',  # set from datasource
+    }
+    # set parent
+    formdata.submission_context = {
+        'orig_object_type': 'carddef',
+        'orig_formdata_id': '2',
+        'orig_formdef_id': str(carddef.id),
+    }
+    formdata.user_id = user.id
+    formdata.store()
+    formdata.just_created()
+    formdata.perform_workflow()
+    assert carddef.data_class().count() == 6
+    assert carddef.data_class().get(1).user_id == str(user.id)
+    assert carddef.data_class().get(2).user_id is None
+    assert carddef.data_class().get(3).user_id is None
+    assert carddef.data_class().get(4).user_id is None  # not changed
+    assert carddef.data_class().get(5).user_id is None
+    assert carddef.data_class().get(6).user_id is None
+    assert two_pubs.loggederror_class.count() == 1
+    logged_error = two_pubs.loggederror_class.select()[0]
+    assert logged_error.summary == 'Could not find targeted "Parent" object by id 424'
+
+    # slug not or badly configured
+    assign.target_id = '{{ form_var_string }}'  # == '5'
+    assign.formdef_slug = None
+    wf.store()
+    formdata = formdef.data_class()()
+    formdata.data = {
+        '0': 'Parent',
+        '1': 'Foo',
+        '2': '5',
+        '3': '3',  # set from datasource
+    }
+    # set parent
+    formdata.submission_context = {
+        'orig_object_type': 'carddef',
+        'orig_formdata_id': '2',
+        'orig_formdef_id': str(carddef.id),
+    }
+    formdata.user_id = user.id
+    formdata.store()
+    formdata.just_created()
+    formdata.perform_workflow()
+    assert carddef.data_class().count() == 7
+    assert carddef.data_class().get(1).user_id == str(user.id)
+    assert carddef.data_class().get(2).user_id is None
+    assert carddef.data_class().get(3).user_id is None
+    assert carddef.data_class().get(4).user_id is None
+    assert carddef.data_class().get(5).user_id is None  # not changed
+    assert carddef.data_class().get(6).user_id is None
+    assert carddef.data_class().get(7).user_id is None
+
+
+def test_assign_carddata_targeting_itself(pub):
+    CardDef.wipe()
+    pub.user_class.wipe()
+
+    user = pub.user_class()
+    user.email = 'test@example.net'
+    user.name_identifiers = ['xyz']
+    user.store()
+
+    carddef = CardDef()
+    carddef.name = 'Foo Card'
+    carddef.fields = [
+        StringField(id='0', label='foo', varname='foo'),
+    ]
+    carddef.store()
+    carddef.data_class().wipe()
+
+    # card workflow: assign itself then jump to second status
+    card_wf = Workflow(name='Card workflow')
+    st1 = card_wf.add_status('Status1')
+    st2 = card_wf.add_status('Status2')
+
+    assign = AssignCarddataWorkflowStatusItem()
+    assign.id = '_assign'
+    assign.formdef_slug = carddef.url_name
+    assign.target_mode = 'manual'
+    assign.target_id = '{{ form_internal_id }}'  # itself
+    assign.user_association_mode = 'custom'
+    assign.user_association_template = 'xyz'
+    assign.parent = st1
+    st1.items.append(assign)
+
+    jump = JumpWorkflowStatusItem()
+    jump.id = '_jump'
+    jump.status = st2.id
+    st1.items.append(jump)
+    jump.parent = st1
+
+    card_wf.store()
+
+    carddef.workflow = card_wf
+    carddef.store()
+
+    # create some cardata
+    for i in range(1, 4):
+        carddata = carddef.data_class()()
+        carddata.data = {
+            '0': 'foo %s' % i,
+        }
+        carddata.user_id = 42
+        carddata.store()
+        # run workflow, verify that carddata is assign
+        carddata.just_created()
+        carddata.perform_workflow()
+        assert carddata.user_id == user.id
+        assert carddata.status == 'wf-%s' % st2.id
+
+
+def test_assign_carddata_from_created_object(pub):
+    FormDef.wipe()
+    CardDef.wipe()
+    pub.user_class.wipe()
+
+    user = pub.user_class()
+    user.email = 'test@example.net'
+    user.name_identifiers = ['xyz']
+    user.store()
+
+    carddef = CardDef()
+    carddef.name = 'Card'
+    carddef.fields = [
+        StringField(id='0', label='Card Field', varname='card_field'),
+    ]
+    carddef.store()
+    carddef.data_class().wipe()
+
+    formdef = FormDef()
+    formdef.name = 'Form'
+    formdef.fields = [
+        StringField(id='0', label='Form Field', varname='form_field'),
+    ]
+    formdef.store()
+
+    # card workflow: create formdata then jump to second status
+    card_wf = Workflow(name='Card workflow')
+    st1 = card_wf.add_status('Status1')
+    st2 = card_wf.add_status('Status2')
+
+    create = CreateFormdataWorkflowStatusItem()
+    create.id = '_create'
+    create.formdef_slug = formdef.url_name
+    create.mappings = [
+        Mapping(field_id='0', expression='...'),
+    ]
+    create.parent = st1
+    st1.items.append(create)
+
+    jump = JumpWorkflowStatusItem()
+    jump.id = '_jump'
+    jump.status = st2.id
+    st1.items.append(jump)
+    jump.parent = st1
+
+    # form workflow: assign parent card data
+    form_wf = Workflow(name='Form workflow')
+    st1 = form_wf.add_status('Status1')
+    assign = AssignCarddataWorkflowStatusItem()
+    assign.formdef_slug = carddef.url_name
+    assign.user_association_mode = 'custom'
+    assign.user_association_template = 'xyz'
+    assign.id = 'assign'
+    st1.items.append(assign)
+    assign.parent = st1
+    form_wf.store()
+
+    carddef.workflow = card_wf
+    carddef.store()
+
+    formdef.workflow = form_wf
+    formdef.store()
+
+    carddata = carddef.data_class()()
+    carddata.data = {'0': 'Foo'}
+    carddata.store()
+    carddata.just_created()
+    carddata.perform_workflow()
+    assert carddata.user_id == user.id
+
+    carddata_reloaded = carddata.get(carddata.id)
+    assert carddata_reloaded.status == 'wf-2'
+    assert carddata_reloaded.user_id == user.id
+
+
+def test_assign_carddata_user_association(two_pubs):
+    CardDef.wipe()
+    FormDef.wipe()
+    two_pubs.user_class.wipe()
+
+    user = two_pubs.user_class()
+    user.email = 'test@example.net'
+    user.name_identifiers = ['xyz']
+    user.store()
+    user2 = two_pubs.user_class()
+    user2.store()
+
+    carddef = CardDef()
+    carddef.name = 'Person'
+    carddef.fields = [
+        StringField(id='0', label='First Name', varname='first_name'),
+        StringField(id='1', label='Last Name', varname='last_name'),
+    ]
+    carddef.digest_templates = {'default': '{{ form_var_first_name }} {{ form_var_last_name }}'}
+    carddef.user_support = 'optional'
+    carddef.store()
+    carddef.data_class().wipe()
+
+    carddata = carddef.data_class()()
+    carddata.data = {'0': 'Foo', '1': 'Bar'}
+    carddata.user_id = user2.id
+    carddata.store()
+
+    wf = Workflow(name='assign-carddata')
+    wf.possible_status = Workflow.get_default_workflow().possible_status[:]
+    assign = AssignCarddataWorkflowStatusItem()
+    assign.label = 'Assign CardDef'
+    assign.varname = 'mycard'
+    assign.id = '_assign'
+    assign.formdef_slug = carddef.url_name
+    assign.parent = wf.possible_status[1]
+    wf.possible_status[1].items.insert(0, assign)
+    wf.store()
+
+    datasource = {'type': 'carddef:%s' % carddef.url_name}
+    formdef = FormDef()
+    formdef.name = 'Persons'
+    formdef.fields = [
+        ItemField(id='0', label='Person', type='item', varname='person', data_source=datasource),
+    ]
+    formdef.workflow = wf
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.data = {'0': '1'}
+    formdata.user_id = user.id
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    assert carddef.data_class().select()[0].user is None
+
+    # keep user
+    carddata.user_id = user2.id
+    carddata.store()
+    assign.user_association_mode = 'keep-user'
+    wf.store()
+
+    formdata = FormDef.get(formdef.id).data_class()()
+    formdata.data = {'0': '1'}
+    formdata.user_id = user.id
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    assert carddef.data_class().select()[0].user.id == user.id
+
+    # user association on direct user
+    carddata.user_id = user2.id
+    carddata.store()
+    assign.user_association_mode = 'custom'
+    assign.user_association_template = '{{ form_user }}'
+    wf.store()
+
+    formdata = FormDef.get(formdef.id).data_class()()
+    formdata.data = {'0': '1'}
+    formdata.user_id = user.id
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    assert carddef.data_class().select()[0].user.id == user.id
+
+    # user association on user email
+    carddata.user_id = user2.id
+    carddata.store()
+    assign.user_association_mode = 'custom'
+    assign.user_association_template = 'test@example.net'
+    wf.store()
+
+    formdata = FormDef.get(formdef.id).data_class()()
+    formdata.data = {'0': '1'}
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    assert carddef.data_class().select()[0].user.id == user.id
+
+    # user association on name id
+    carddata.user_id = user2.id
+    carddata.store()
+    assign.user_association_mode = 'custom'
+    assign.user_association_template = 'xyz'
+    wf.store()
+
+    formdata = FormDef.get(formdef.id).data_class()()
+    formdata.data = {'0': '1'}
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    assert carddef.data_class().select()[0].user.id == user.id
+
+    # user association on invalid user
+    carddata.user_id = user2.id
+    carddata.store()
+    assign.user_association_mode = 'custom'
+    assign.user_association_template = 'zzz'
+    wf.store()
+
+    formdata = FormDef.get(formdef.id).data_class()()
+    formdata.data = {'0': '1'}
+    formdata.just_created()
+    if two_pubs.loggederror_class:
+        two_pubs.loggederror_class.wipe()
+    formdata.perform_workflow()
+
+    assert carddef.data_class().select()[0].user is None
+    if two_pubs.loggederror_class:
+        assert two_pubs.loggederror_class.count() == 1
+        logged_error = two_pubs.loggederror_class.select()[0]
+        assert logged_error.summary == 'Failed to attach user (not found: "zzz")'
+        assert logged_error.formdata_id == str(formdata.id)
+
+    # user association on invalid template
+    carddata.user_id = user2.id
+    carddata.store()
+    assign.user_association_mode = 'custom'
+    assign.user_association_template = '{% %}'
+    wf.store()
+
+    formdata = FormDef.get(formdef.id).data_class()()
+    formdata.data = {'0': '1'}
+    formdata.just_created()
+    formdata.perform_workflow()
+
+    assert carddef.data_class().select()[0].user is None
diff --git a/wcs/wf/assign_carddata.py b/wcs/wf/assign_carddata.py
new file mode 100644
index 00000000..3e935736
--- /dev/null
+++ b/wcs/wf/assign_carddata.py
@@ -0,0 +1,71 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2021  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+
+from quixote import get_publisher
+
+from wcs.qommon import _
+from wcs.wf.create_carddata import CreateCarddataWorkflowStatusItem
+from wcs.wf.external_workflow import ExternalWorkflowGlobalAction
+from wcs.workflows import register_item_class
+
+
+class AssignCarddataWorkflowStatusItem(CreateCarddataWorkflowStatusItem, ExternalWorkflowGlobalAction):
+    description = _('Assign Card Data')
+    key = 'assign_carddata'
+    automatic_targetting = _('Assign linked cards')
+    manual_targetting = _('Specify the identifier of the card which will be assigned')
+    always_show_user_fields = True
+    allow_python = False
+    django_only = True
+
+    def get_parameters(self):
+        return (
+            'formdef_slug',
+            'target_mode',
+            'target_id',
+            'user_association_mode',
+            'user_association_template',
+            'condition',
+        )
+
+    @property
+    def slug(self):
+        # act only on linked carddefs
+        return 'carddef:%s' % self.formdef_slug
+
+    def get_line_details(self):
+        if not self.formdef:
+            return _('not configured')
+        return self.formdef.name
+
+    def perform(self, formdata):
+        carddef = self.formdef
+        if not carddef:
+            return
+
+        formdata.store()
+
+        for target_data in self.iter_target_datas(formdata, carddef):
+            self.assign_user(dest=target_data, src=formdata)
+            with get_publisher().substitutions.freeze():
+                target_data.store()
+
+        # update local object as it may have modified itself
+        formdata.refresh_from_storage()
+
+
+register_item_class(AssignCarddataWorkflowStatusItem)
diff --git a/wcs/wf/create_formdata.py b/wcs/wf/create_formdata.py
index 205f0f15..b6f9e837 100644
--- a/wcs/wf/create_formdata.py
+++ b/wcs/wf/create_formdata.py
@@ -365,7 +365,8 @@ class CreateFormdataWorkflowStatusItem(WorkflowStatusItem):
                 ],
                 value=self.user_association_mode,
                 attrs={'data-dynamic-display-parent': 'true'},
-                advanced=bool(self.user_association_mode),
+                advanced=not getattr(self, 'always_show_user_fields', False)
+                and bool(self.user_association_mode),
             )
         if 'user_association_template' in parameters:
             form.add(
@@ -377,7 +378,9 @@ class CreateFormdataWorkflowStatusItem(WorkflowStatusItem):
                     'data-dynamic-display-child-of': '%suser_association_mode' % prefix,
                     'data-dynamic-display-value': 'custom',
                 },
-                advanced=bool(self.user_association_mode),
+                advanced=not getattr(self, 'always_show_user_fields', False)
+                and bool(self.user_association_mode),
+                allow_python=getattr(self, 'allow_python', True),
             )
         if 'keep_submission_context' in parameters:
             form.add(
@@ -493,22 +496,15 @@ class CreateFormdataWorkflowStatusItem(WorkflowStatusItem):
             return _('not configured')
         return self.formdef.name
 
-    def perform(self, formdata):
-        formdef = self.formdef
-        if not formdef or not (self.mappings or self.map_fields_by_varname):
-            return
-
-        new_formdata = formdef.data_class()()
-        new_formdata.receipt_time = time.localtime()
-
+    def assign_user(self, dest, src):
         if self.user_association_mode == 'keep-user':
-            new_formdata.user_id = formdata.user_id
+            dest.user_id = src.user_id
         elif self.user_association_mode == 'custom' and self.user_association_template:
             with get_publisher().complex_data():
                 try:
                     value = self.compute(
                         self.user_association_template,
-                        formdata=formdata,
+                        formdata=src,
                         raises=True,
                         allow_complex=True,
                         status_item=self,
@@ -524,15 +520,27 @@ class CreateFormdataWorkflowStatusItem(WorkflowStatusItem):
             if isinstance(value, LazyUser):
                 value = value._user
             if isinstance(value, get_publisher().user_class):
-                new_formdata.user = value
+                dest.user = value
             else:
-                new_formdata.user = get_publisher().user_class.lookup_by_string(value)
-                if value and not new_formdata.user:
+                dest.user = get_publisher().user_class.lookup_by_string(value)
+                if value and not dest.user:
                     get_publisher().record_error(
                         _('Failed to attach user (not found: "%s")') % value,
-                        formdata=formdata,
+                        formdata=src,
                         status_item=self,
                     )
+        else:
+            dest.user_id = None
+
+    def perform(self, formdata):
+        formdef = self.formdef
+        if not formdef or not (self.mappings or self.map_fields_by_varname):
+            return
+
+        new_formdata = formdef.data_class()()
+        new_formdata.receipt_time = time.localtime()
+
+        self.assign_user(dest=new_formdata, src=formdata)
 
         if self.keep_submission_context:
             new_formdata.submission_context = formdata.submission_context or {}
diff --git a/wcs/wf/external_workflow.py b/wcs/wf/external_workflow.py
index 14959c38..c2d48f85 100644
--- a/wcs/wf/external_workflow.py
+++ b/wcs/wf/external_workflow.py
@@ -153,6 +153,7 @@ class ExternalWorkflowGlobalAction(WorkflowStatusItem):
                     'data-dynamic-display-child-of': 'target_mode',
                     'data-dynamic-display-value': 'manual',
                 },
+                allow_python=getattr(self, 'allow_python', True),
             )
 
         if 'trigger_id' in parameters and form.get('%sslug' % prefix):
diff --git a/wcs/workflows.py b/wcs/workflows.py
index 52bf13b6..666f50d3 100644
--- a/wcs/workflows.py
+++ b/wcs/workflows.py
@@ -2176,6 +2176,7 @@ class WorkflowStatusItem(XmlSerialisable):
                 value=self.condition,
                 size=40,
                 advanced=not (self.condition),
+                django_only=getattr(self, 'django_only', False),
             )
 
         if 'attachments' in parameters:
@@ -3564,6 +3565,7 @@ register_item_class(EditableWorkflowStatusItem)
 def load_extra():
     from .wf import aggregation_email  # noqa pylint: disable=unused-import
     from .wf import anonymise  # noqa pylint: disable=unused-import
+    from .wf import assign_carddata  # noqa pylint: disable=unused-import
     from .wf import attachment  # noqa pylint: disable=unused-import
     from .wf import backoffice_fields  # noqa pylint: disable=unused-import
     from .wf import create_carddata  # noqa pylint: disable=unused-import
-- 
2.33.0

