From b718a51ff419366deb59fa04fc55231d963c692a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Sat, 24 Sep 2022 18:43:55 +0200
Subject: [PATCH 2/3] formdata: store data history (#62800)

---
 tests/form_pages/test_all.py      |   2 +-
 tests/form_pages/test_formdata.py |  24 +-
 tests/test_content_snapshots.py   | 741 ++++++++++++++++++++++++++++++
 tests/workflow/test_all.py        |  53 ++-
 wcs/api.py                        |   6 +
 wcs/formdata.py                   |   9 +-
 wcs/forms/root.py                 |   5 +-
 wcs/wf/backoffice_fields.py       |   9 +-
 wcs/wf/edit_carddata.py           |  12 +-
 wcs/workflows.py                  |  11 +
 10 files changed, 833 insertions(+), 39 deletions(-)
 create mode 100644 tests/test_content_snapshots.py

diff --git a/tests/form_pages/test_all.py b/tests/form_pages/test_all.py
index c9e867f96..08a3e2d7b 100644
--- a/tests/form_pages/test_all.py
+++ b/tests/form_pages/test_all.py
@@ -9129,7 +9129,7 @@ def test_structured_workflow_options(pub):
         '1_display': 'un',
         '1_structured': {'id': '1', 'text': 'un', 'more': 'foo'},
     }
-    assert '2020-04-18' in formdata.evolution[0].parts[0].content
+    assert '2020-04-18' in formdata.evolution[0].parts[1].content
 
 
 def test_exclude_self_condition(pub):
diff --git a/tests/form_pages/test_formdata.py b/tests/form_pages/test_formdata.py
index cd66b7076..5dd20e2a6 100644
--- a/tests/form_pages/test_formdata.py
+++ b/tests/form_pages/test_formdata.py
@@ -21,7 +21,7 @@ from wcs.qommon.misc import ConnectionError
 from wcs.wf.create_formdata import Mapping
 from wcs.wf.export_to_model import transform_to_pdf
 from wcs.wf.form import WorkflowFormFieldsFormDef
-from wcs.workflows import Workflow, WorkflowBackofficeFieldsFormDef
+from wcs.workflows import ContentSnapshotPart, Workflow, WorkflowBackofficeFieldsFormDef
 from wcs.wscalls import NamedWsCall
 
 from ..utilities import clean_temporary_pub, create_temporary_pub, get_app, login
@@ -292,8 +292,10 @@ def test_formdata_attachment_download_to_backoffice_file_field_only(pub):
     assert bo1.get_content() == b'foobar'
 
     # but nothing in history
-    for evo in formdata.evolution:
-        assert not evo.parts
+    assert len(formdata.evolution) == 2
+    assert len(formdata.evolution[0].parts) == 1
+    assert isinstance(formdata.evolution[0].parts[0], ContentSnapshotPart)
+    assert formdata.evolution[1].parts is None
 
 
 def test_formdata_attachment_file_options(pub):
@@ -1456,7 +1458,7 @@ def test_formdata_named_wscall(http_requests, pub):
     assert 'The form has been recorded and: XbarY' in resp.text
 
     formdata = formdef.data_class().select()[0]
-    assert formdata.evolution[0].parts[0].content == 'Hello bar World'
+    assert formdata.evolution[0].parts[1].content == 'Hello bar World'
 
     # check with publisher variable in named webservice call
     if not pub.site_options.has_section('variables'):
@@ -1481,7 +1483,7 @@ def test_formdata_named_wscall(http_requests, pub):
     assert 'The form has been recorded and: XbarY' in resp.text
 
     formdata = formdef.data_class().select()[0]
-    assert formdata.evolution[0].parts[0].content == 'Hello bar World'
+    assert formdata.evolution[0].parts[1].content == 'Hello bar World'
 
 
 def test_formdata_named_wscall_in_conditions(http_requests, pub):
@@ -1607,8 +1609,8 @@ def test_formdata_evolution_registercommenter_to(pub):
     assert 'The form has been recorded' in resp.text
 
     formdata = formdef.data_class().select()[0]
-    assert formdata.evolution[0].parts[0].content == 'Hello World'
-    assert formdata.evolution[0].parts[0].to is None
+    assert formdata.evolution[0].parts[1].content == 'Hello World'
+    assert formdata.evolution[0].parts[1].to is None
     resp = app.get('/test/%s/' % formdata.id)
     resp.status_int = 200
     assert resp.html.find('div', {'id': 'evolution-log'}).find('p').text == 'Hello World'
@@ -1627,8 +1629,8 @@ def test_formdata_evolution_registercommenter_to(pub):
     assert 'The form has been recorded' in resp.text
 
     formdata = formdef.data_class().select()[0]
-    assert formdata.evolution[0].parts[0].content == 'Hello World'
-    assert formdata.evolution[0].parts[0].to == [role1.id]
+    assert formdata.evolution[0].parts[1].content == 'Hello World'
+    assert formdata.evolution[0].parts[1].to == [role1.id]
     resp = app.get('/test/%s/' % formdata.id)
     resp.status_int = 200
     assert not resp.html.find('div', {'id': 'evolution-log'}).find('p')
@@ -1647,8 +1649,8 @@ def test_formdata_evolution_registercommenter_to(pub):
     assert 'The form has been recorded' in resp.text
 
     formdata = formdef.data_class().select()[0]
-    assert formdata.evolution[0].parts[0].content == 'Hello World'
-    assert formdata.evolution[0].parts[0].to == [role2.id]
+    assert formdata.evolution[0].parts[1].content == 'Hello World'
+    assert formdata.evolution[0].parts[1].to == [role2.id]
     resp = app.get('/test/%s/' % formdata.id)
     resp.status_int = 200
     assert resp.html.find('div', {'id': 'evolution-log'}).find('p').text == 'Hello World'
diff --git a/tests/test_content_snapshots.py b/tests/test_content_snapshots.py
new file mode 100644
index 000000000..3fcde915c
--- /dev/null
+++ b/tests/test_content_snapshots.py
@@ -0,0 +1,741 @@
+import json
+import os
+
+import pytest
+from webtest import Upload
+
+from wcs import fields
+from wcs.api_access import ApiAccess
+from wcs.carddef import CardDef
+from wcs.formdef import FormDef
+from wcs.qommon.http_request import HTTPRequest
+from wcs.qommon.ident.password_accounts import PasswordAccount
+from wcs.wf.backoffice_fields import SetBackofficeFieldsWorkflowStatusItem
+from wcs.wf.create_formdata import Mapping
+from wcs.workflows import ContentSnapshotPart, Workflow, WorkflowBackofficeFieldsFormDef
+
+from .utilities import clean_temporary_pub, create_temporary_pub, get_app, login
+
+
+@pytest.fixture
+def pub(emails):
+    pub = create_temporary_pub()
+
+    req = HTTPRequest(None, {'SCRIPT_NAME': '/', 'SERVER_NAME': 'example.net'})
+    pub.set_app_dir(req)
+    pub.cfg['identification'] = {'methods': ['password']}
+    pub.cfg['language'] = {'language': 'en'}
+    pub.write_cfg()
+    with open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w') as fd:
+        fd.write(
+            '''
+    [api-secrets]
+    coucou = 1234
+    '''
+        )
+
+    return pub
+
+
+def teardown_module(module):
+    clean_temporary_pub()
+
+
+@pytest.fixture
+def role(pub):
+    pub.role_class.wipe()
+    role = pub.role_class(name='foobar')
+    role.store()
+    return role
+
+
+@pytest.fixture
+def user(pub, role):
+    pub.user_class.wipe()
+    user = pub.user_class()
+    user.name = 'Jean Darmette'
+    user.email = 'jean.darmette@triffouilis.fr'
+    user.name_identifiers = ['0123456789']
+    user.roles = [role.id]
+    user.store()
+
+    account = PasswordAccount(id='admin')
+    account.set_password('admin')
+    account.user_id = user.id
+    account.store()
+
+    return user
+
+
+@pytest.fixture
+def access(pub, role):
+    ApiAccess.wipe()
+    access = ApiAccess()
+    access.name = 'test'
+    access.access_identifier = 'test'
+    access.access_key = '12345'
+    access.roles = [role]
+    access.store()
+    return access
+
+
+def test_formdata_create_and_edit_and_bo_field(pub, user):
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'test'
+    formdef.fields = [
+        fields.StringField(id='1', label='string', varname='foo'),
+    ]
+    formdef.store()
+    formdef.data_class().wipe()
+
+    Workflow.wipe()
+    workflow = Workflow(name='test')
+    workflow.backoffice_fields_formdef = WorkflowBackofficeFieldsFormDef(workflow)
+    workflow.backoffice_fields_formdef.fields = [
+        fields.StringField(id='bo1', label='bo field 1', type='string', varname='plop'),
+    ]
+    st1 = workflow.add_status('Status1', 'st1')
+    setbo = st1.add_action('set-backoffice-fields')
+    setbo.fields = [{'field_id': 'bo1', 'value': '{{ form_var_foo }}'}]
+    setbo2 = st1.add_action('set-backoffice-fields')
+    setbo2.fields = [{'field_id': 'bo1', 'value': '{{ "foo"|add:form_var_plop }}'}]
+    jump = st1.add_action('jump')
+    jump.status = 'st2'
+
+    st2 = workflow.add_status('Status2', 'st2')
+
+    editable = st2.add_action('editable', id='_editable')
+    editable.by = ['_submitter']
+    editable.status = st1.id
+    workflow.store()
+
+    formdef.workflow_id = workflow.id
+    formdef.store()
+    formdef.data_class().wipe()
+
+    app = login(get_app(pub))
+    resp = app.get('/test/')
+    resp.form['f1'] = 'bar'
+    resp = resp.form.submit('submit')  # -> validation
+    resp = resp.form.submit('submit').follow()  # -> submitted
+    assert 'The form has been recorded' in resp.text
+
+    data_id = formdef.data_class().select()[0].id
+    resp = app.get('/test/%s/' % data_id)
+    assert 'button_editable-button' in resp.text
+
+    resp = resp.form.submit('button_editable')
+    resp = resp.follow()
+    assert resp.form['f1'].value == 'bar'
+    resp.form['f1'].value = 'baz'
+    resp = resp.form.submit('submit').follow()  # -> saved
+
+    formdata = formdef.data_class().get(data_id)
+    assert isinstance(formdata.evolution[0].parts[0], ContentSnapshotPart)
+    # creation, submit
+    assert formdata.evolution[0].parts[0].formdef_type == 'formdef'
+    assert formdata.evolution[0].parts[0].formdef_id == str(formdef.id)
+    assert formdata.evolution[0].parts[0].old_data == {}
+    assert formdata.evolution[0].parts[0].new_data == {'1': 'bar', 'bo1': None}
+    dt1 = formdata.evolution[0].parts[0].datetime
+    # creation, bo field first action
+    assert formdata.evolution[0].parts[1].formdef_type == 'formdef'
+    assert formdata.evolution[0].parts[1].formdef_id == str(formdef.id)
+    assert formdata.evolution[0].parts[1].old_data == {'1': 'bar'}
+    assert formdata.evolution[0].parts[1].new_data == {'1': 'bar', 'bo1': 'bar'}
+    dt2 = formdata.evolution[0].parts[1].datetime
+    assert dt2 > dt1
+    # creation, bo field second action
+    assert formdata.evolution[0].parts[2].formdef_type == 'formdef'
+    assert formdata.evolution[0].parts[2].formdef_id == str(formdef.id)
+    assert formdata.evolution[0].parts[2].old_data == {'1': 'bar', 'bo1': 'bar'}
+    assert formdata.evolution[0].parts[2].new_data == {'1': 'bar', 'bo1': 'foobar'}
+    dt3 = formdata.evolution[0].parts[2].datetime
+    assert dt3 > dt2
+    # update, submit
+    assert formdata.evolution[1].parts[0].formdef_type == 'formdef'
+    assert formdata.evolution[1].parts[0].formdef_id == str(formdef.id)
+    assert formdata.evolution[1].parts[0].old_data == {'1': 'bar', 'bo1': 'foobar'}
+    assert formdata.evolution[1].parts[0].new_data == {'1': 'baz', 'bo1': 'foobar'}
+    dt4 = formdata.evolution[1].parts[0].datetime
+    assert dt4 > dt3
+    # update, bo field first action
+    assert formdata.evolution[2].parts[0].formdef_type == 'formdef'
+    assert formdata.evolution[2].parts[0].formdef_id == str(formdef.id)
+    assert formdata.evolution[2].parts[0].old_data == {'1': 'baz', 'bo1': 'foobar'}
+    assert formdata.evolution[2].parts[0].new_data == {'1': 'baz', 'bo1': 'baz'}
+    dt5 = formdata.evolution[2].parts[0].datetime
+    assert dt5 > dt4
+    # update, bo field second action
+    assert formdata.evolution[2].parts[1].formdef_type == 'formdef'
+    assert formdata.evolution[2].parts[1].formdef_id == str(formdef.id)
+    assert formdata.evolution[2].parts[1].old_data == {'1': 'baz', 'bo1': 'baz'}
+    assert formdata.evolution[2].parts[1].new_data == {'1': 'baz', 'bo1': 'foobaz'}
+    dt6 = formdata.evolution[2].parts[1].datetime
+    assert dt6 > dt5
+
+
+def test_backoffice_formdata_submission(pub, user):
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.StringField(id='1', label='String', type='string'),
+    ]
+    formdef.workflow_roles = {'_receiver': user.roles[0]}
+    formdef.backoffice_submission_roles = user.roles[:]
+    formdef.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/submission/form-title/')
+    resp.form['f1'] = 'test submission'
+    resp = resp.form.submit('submit')  # -> validation
+    resp = resp.form.submit('submit').follow()  # -> submitted
+    assert 'The form has been recorded' in resp.text
+
+    formdata = formdef.data_class().select()[0]
+    assert isinstance(formdata.evolution[0].parts[0], ContentSnapshotPart)
+    assert formdata.evolution[0].parts[0].formdef_type == 'formdef'
+    assert formdata.evolution[0].parts[0].formdef_id == str(formdef.id)
+    assert formdata.evolution[0].parts[0].old_data == {}
+    assert formdata.evolution[0].parts[0].new_data == {'1': 'test submission'}
+
+
+def test_backoffice_carddata_add_edit(pub, user):
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'test'
+    carddef.fields = [
+        fields.StringField(id='1', label='string', varname='foo'),
+    ]
+    carddef.backoffice_submission_roles = user.roles
+    carddef.workflow_roles = {
+        '_viewer': user.roles[0],
+        '_editor': user.roles[0],
+    }
+    carddef.store()
+    carddef.data_class().wipe()
+
+    Workflow.wipe()
+    workflow = Workflow(name='test')
+    workflow.roles = {
+        '_viewer': 'Viewer',
+        '_editor': 'Editor',
+    }
+    st1 = workflow.add_status('Status1', 'st1')
+    jump = st1.add_action('jump')
+    jump.status = 'st2'
+    st2 = workflow.add_status('Status2', 'st2')
+    editable = st2.add_action('editable', id='_editable')
+    editable.by = ['_editor']
+    editable.status = st1.id
+    workflow.store()
+
+    carddef.workflow_id = workflow.id
+    carddef.store()
+    carddef.data_class().wipe()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/data/test/add/')
+    resp.form['f1'] = 'foo'
+    resp = resp.form.submit('submit').follow()
+    assert 'button_editable-button' in resp.text
+
+    resp = resp.form.submit('button_editable')
+    resp = resp.follow()
+    resp.form['f1'].value = 'bar'
+    resp = resp.form.submit('submit').follow()
+
+    carddata = carddef.data_class().select()[0]
+    # creation
+    assert isinstance(carddata.evolution[0].parts[0], ContentSnapshotPart)
+    assert carddata.evolution[0].parts[0].formdef_type == 'carddef'
+    assert carddata.evolution[0].parts[0].formdef_id == str(carddef.id)
+    assert carddata.evolution[0].parts[0].old_data == {}
+    assert carddata.evolution[0].parts[0].new_data == {'1': 'foo'}
+    dt1 = carddata.evolution[0].parts[0].datetime
+    # update
+    assert isinstance(carddata.evolution[1].parts[0], ContentSnapshotPart)
+    assert carddata.evolution[1].parts[0].formdef_type == 'carddef'
+    assert carddata.evolution[1].parts[0].formdef_id == str(carddef.id)
+    assert carddata.evolution[1].parts[0].old_data == {'1': 'foo'}
+    assert carddata.evolution[1].parts[0].new_data == {'1': 'bar'}
+    dt2 = carddata.evolution[1].parts[0].datetime
+    assert dt2 > dt1
+
+
+def test_backoffice_card_import_csv(pub, user):
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'test'
+    carddef.fields = [
+        fields.StringField(id='1', label='Test'),
+        fields.StringField(id='2', label='Test2'),
+    ]
+    carddef.workflow_roles = {'_editor': user.roles[0]}
+    carddef.backoffice_submission_roles = user.roles
+    carddef.store()
+    carddef.data_class().wipe()
+
+    app = login(get_app(pub))
+
+    resp = app.get(carddef.get_url())
+    resp = resp.click('Import data from a file')
+    data = [b'Test,Test2']
+    data.append(b'data,foo')
+
+    resp.forms[0]['file'] = Upload('test.csv', b'\n'.join(data), 'text/csv')
+    resp = resp.forms[0].submit().follow()
+    assert 'Importing data into cards' in resp
+    assert carddef.data_class().count() == 1
+    carddata = carddef.data_class().select()[0]
+    assert isinstance(carddata.evolution[-1].parts[0], ContentSnapshotPart)
+    assert carddata.evolution[0].parts[0].formdef_type == 'carddef'
+    assert carddata.evolution[0].parts[0].formdef_id == str(carddef.id)
+    assert carddata.evolution[0].parts[0].old_data == {}
+    assert carddata.evolution[0].parts[0].new_data == {'1': 'data', '2': 'foo'}
+
+
+def test_backoffice_card_import_json(pub, user):
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'test'
+    carddef.fields = [
+        fields.StringField(id='1', label='Test', varname='string'),
+    ]
+    carddef.workflow_roles = {'_editor': user.roles[0]}
+    carddef.backoffice_submission_roles = user.roles
+    carddef.store()
+    carddef.data_class().wipe()
+
+    app = login(get_app(pub))
+    resp = app.get(carddef.get_url())
+    resp = resp.click('Import data from a file')
+    data = {
+        'data': [
+            {
+                'fields': {
+                    'string': 'a string',
+                }
+            }
+        ]
+    }
+    resp.forms[0]['file'] = Upload('test.json', json.dumps(data).encode(), 'application/json')
+    resp = resp.forms[0].submit()
+    assert '/backoffice/processing?job=' in resp.location
+    resp = resp.follow()
+
+    carddata = carddef.data_class().select()[0]
+    assert isinstance(carddata.evolution[-1].parts[0], ContentSnapshotPart)
+    assert carddata.evolution[-1].parts[0].formdef_type == 'carddef'
+    assert carddata.evolution[-1].parts[0].formdef_id == str(carddef.id)
+    assert carddata.evolution[-1].parts[0].old_data == {}
+    assert carddata.evolution[-1].parts[0].new_data == {'1': 'a string'}
+
+
+def test_workflow_formdata_create(pub):
+    FormDef.wipe()
+    pub.tracking_code_class.wipe()
+
+    target_formdef = FormDef()
+    target_formdef.name = 'target form'
+    target_formdef.fields = [
+        fields.StringField(id='0', label='string', varname='string'),
+    ]
+    target_formdef.store()
+
+    wf = Workflow(name='create-formdata')
+    wf.possible_status = Workflow.get_default_workflow().possible_status[:]
+    create = wf.possible_status[1].add_action('create_formdata', id='_create', prepend=True)
+    create.formdef_slug = target_formdef.url_name
+    create.label = 'create a new linked form'
+    create.varname = 'resubmitted'
+    create.mappings = [
+        Mapping(field_id='0', expression='{{ form_var_foo }}'),
+    ]
+    wf.store()
+
+    source_formdef = FormDef()
+    source_formdef.name = 'source form'
+    source_formdef.fields = [
+        fields.StringField(id='0', label='string', varname='foo'),
+    ]
+    source_formdef.workflow_id = wf.id
+    source_formdef.store()
+
+    formdata = source_formdef.data_class()()
+    formdata.data = {'0': 'foobar'}
+    formdata.just_created()
+    formdata.store()
+
+    formdata.perform_workflow()
+    assert target_formdef.data_class().count() == 1
+    created_formdata = target_formdef.data_class().select()[0]
+    assert isinstance(created_formdata.evolution[0].parts[0], ContentSnapshotPart)
+    assert created_formdata.evolution[0].parts[0].formdef_type == 'formdef'
+    assert created_formdata.evolution[0].parts[0].formdef_id == str(target_formdef.id)
+    assert created_formdata.evolution[0].parts[0].old_data == {}
+    assert created_formdata.evolution[0].parts[0].new_data == {'0': 'foobar'}
+
+
+def test_workflow_carddata_create(pub):
+    CardDef.wipe()
+    FormDef.wipe()
+
+    carddef = CardDef()
+    carddef.name = 'My card'
+    carddef.fields = [
+        fields.StringField(id='1', label='string'),
+    ]
+    carddef.store()
+
+    wf = Workflow(name='create-carddata')
+    wf.possible_status = Workflow.get_default_workflow().possible_status[:]
+    create = wf.possible_status[1].add_action('create_carddata', id='_create', prepend=True)
+    create.label = 'Create CardDef'
+    create.varname = 'mycard'
+    create.formdef_slug = carddef.url_name
+    create.mappings = [
+        Mapping(field_id='1', expression='{{ form_var_string }}'),
+    ]
+    wf.store()
+
+    formdef = FormDef()
+    formdef.name = 'source form'
+    formdef.fields = [
+        fields.StringField(id='1', label='string', varname='string'),
+    ]
+    formdef.workflow_id = wf.id
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.data = {'1': 'foobar'}
+    formdata.just_created()
+    formdata.store()
+
+    formdata.perform_workflow()
+    assert carddef.data_class().count() == 1
+    carddata = carddef.data_class().select()[0]
+    assert isinstance(carddata.evolution[-1].parts[0], ContentSnapshotPart)
+    assert carddata.evolution[-1].parts[0].formdef_type == 'carddef'
+    assert carddata.evolution[-1].parts[0].formdef_id == str(carddef.id)
+    assert carddata.evolution[-1].parts[0].old_data == {}
+    assert carddata.evolution[-1].parts[0].new_data == {'1': 'foobar'}
+
+
+def test_workflow_carddata_edit(pub):
+    FormDef.wipe()
+    CardDef.wipe()
+
+    # carddef
+    carddef = CardDef()
+    carddef.name = 'Model 1'
+    carddef.fields = [
+        fields.StringField(id='1', label='string', varname='string'),
+    ]
+    carddef.store()
+    carddef.data_class().wipe()
+
+    carddata = carddef.data_class()()
+    carddata.data = {
+        '1': 'foobar',
+    }
+    carddata.just_created()
+    carddata.store()
+
+    # formdef workflow that will update carddata
+    wf = Workflow(name='update')
+    st1 = wf.add_status('New', 'st1')
+    jump = st1.add_action('jump', id='_jump')
+    jump.by = ['_submitter', '_receiver']
+    jump.status = 'st2'
+    st2 = wf.add_status('Update card', 'st2')
+    edit = st2.add_action('edit_carddata', id='edit')
+    edit.formdef_slug = carddef.url_name
+    edit.target_mode = 'manual'
+    edit.target_id = '{{ form_var_card }}'
+    edit.mappings = [
+        Mapping(field_id='1', expression='{{ form_var_foo }}'),
+    ]
+    wf.store()
+
+    # associated formdef
+    formdef = FormDef()
+    formdef.name = 'Update'
+    formdef.fields = [
+        fields.StringField(id='1', label='string', varname='card'),
+        fields.StringField(id='2', label='string', varname='foo'),
+    ]
+    formdef.workflow = wf
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.data = {
+        '1': str(carddata.id),
+        '2': 'foobaz',
+    }
+    formdata.just_created()
+    formdata.store()
+    formdata.perform_workflow()
+    carddata.refresh_from_storage()
+    # creation
+    assert isinstance(carddata.evolution[0].parts[0], ContentSnapshotPart)
+    assert carddata.evolution[0].parts[0].formdef_type == 'carddef'
+    assert carddata.evolution[0].parts[0].formdef_id == str(carddef.id)
+    assert carddata.evolution[0].parts[0].old_data == {}
+    assert carddata.evolution[0].parts[0].new_data == {'1': 'foobar'}
+    dt1 = carddata.evolution[0].parts[0].datetime
+    # update
+    assert isinstance(carddata.evolution[1].parts[0], ContentSnapshotPart)
+    assert carddata.evolution[1].parts[0].formdef_type == 'carddef'
+    assert carddata.evolution[1].parts[0].formdef_id == str(carddef.id)
+    assert carddata.evolution[1].parts[0].old_data == {'1': 'foobar'}
+    assert carddata.evolution[1].parts[0].new_data == {'1': 'foobaz'}
+    dt2 = carddata.evolution[1].parts[0].datetime
+    assert dt2 > dt1
+
+
+def test_workflow_set_backoffice_field(http_requests, pub):
+    Workflow.wipe()
+    FormDef.wipe()
+    wf = Workflow(name='xxx')
+    wf.backoffice_fields_formdef = WorkflowBackofficeFieldsFormDef(wf)
+    wf.backoffice_fields_formdef.fields = [
+        fields.StringField(id='bo1', label='1st backoffice field', type='string', varname='backoffice_blah'),
+    ]
+    st1 = wf.add_status('Status1')
+    wf.store()
+
+    formdef = FormDef()
+    formdef.name = 'baz'
+    formdef.fields = [
+        fields.StringField(id='0', label='String', type='string', varname='string'),
+    ]
+    formdef.workflow_id = wf.id
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.data = {'0': 'HELLO'}
+    formdata.just_created()
+    formdata.store()
+    pub.substitutions.feed(formdata)
+
+    item = SetBackofficeFieldsWorkflowStatusItem()
+    item.fields = [{'field_id': 'bo1', 'value': '{{ form_var_string }}'}]
+    item.parent = st1
+
+    item.perform(formdata)
+    formdata.refresh_from_storage()
+    # creation
+    assert isinstance(formdata.evolution[-1].parts[0], ContentSnapshotPart)
+    assert formdata.evolution[-1].parts[0].formdef_type == 'formdef'
+    assert formdata.evolution[-1].parts[0].formdef_id == str(formdef.id)
+    assert formdata.evolution[-1].parts[0].old_data == {}
+    assert formdata.evolution[-1].parts[0].new_data == {
+        '0': 'HELLO',
+    }
+    dt1 = formdata.evolution[-1].parts[0].datetime
+    # bo action
+    assert isinstance(formdata.evolution[-1].parts[1], ContentSnapshotPart)
+    assert formdata.evolution[-1].parts[1].formdef_type == 'formdef'
+    assert formdata.evolution[-1].parts[1].formdef_id == str(formdef.id)
+    assert formdata.evolution[-1].parts[1].old_data == {
+        '0': 'HELLO',
+    }
+    assert formdata.evolution[-1].parts[1].new_data == {
+        '0': 'HELLO',
+        'bo1': 'HELLO',
+    }
+    dt2 = formdata.evolution[-1].parts[1].datetime
+    assert dt2 > dt1
+
+
+def test_api_form_submit(pub, user, access, role):
+    app = get_app(pub)
+    app.set_authorization(('Basic', ('test', '12345')))
+
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'test'
+    formdef.fields = [
+        fields.StringField(id='1', label='foobar', varname='foobar'),
+    ]
+    formdef.backoffice_submission_roles = [role.id]
+    formdef.store()
+    data_class = formdef.data_class()
+
+    payload = {
+        'data': {
+            'foobar': 'xxx',
+        }
+    }
+    resp = app.post_json(
+        '/api/formdefs/test/submit',
+        payload,
+    )
+    assert resp.json['err'] == 0
+    formdata = data_class.get(resp.json['data']['id'])
+    assert isinstance(formdata.evolution[0].parts[0], ContentSnapshotPart)
+    assert formdata.evolution[0].parts[0].formdef_type == 'formdef'
+    assert formdata.evolution[0].parts[0].formdef_id == str(formdef.id)
+    assert formdata.evolution[0].parts[0].old_data == {}
+    assert formdata.evolution[0].parts[0].new_data == {'1': 'xxx'}
+
+
+def test_api_formdata_edit(pub, user, access, role):
+    app = get_app(pub)
+    app.set_authorization(('Basic', ('test', '12345')))
+
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'test'
+    formdef.fields = [
+        fields.StringField(id='0', label='foobar', varname='foobar'),
+    ]
+    Workflow.wipe()
+    workflow = Workflow(name='foo')
+    workflow.possible_status = Workflow.get_default_workflow().possible_status[:]
+    workflow.store()
+    formdef.workflow_id = workflow.id
+    formdef.workflow_roles = {'_receiver': role.id}
+    formdef.store()
+    formdef.data_class().wipe()
+    formdata = formdef.data_class()()
+    formdata.data = {
+        '0': 'foo@localhost',
+    }
+    formdata.user_id = user.id
+    formdata.just_created()
+    formdata.status = 'wf-new'
+    formdata.evolution[-1].status = 'wf-new'
+    formdata.store()
+
+    wfedit = workflow.possible_status[1].add_action('editable', id='_wfedit')
+    wfedit.by = [user.roles[0]]
+    workflow.store()
+
+    app.post_json(
+        '/api/forms/test/%s/' % formdata.id,
+        {'data': {'0': 'bar@localhost'}},
+    )
+    formdata.refresh_from_storage()
+    # creation
+    assert isinstance(formdata.evolution[-1].parts[0], ContentSnapshotPart)
+    assert formdata.evolution[-1].parts[0].formdef_type == 'formdef'
+    assert formdata.evolution[-1].parts[0].formdef_id == str(formdef.id)
+    assert formdata.evolution[-1].parts[0].old_data == {}
+    assert formdata.evolution[-1].parts[0].new_data == {'0': 'foo@localhost'}
+    dt1 = formdata.evolution[-1].parts[0].datetime
+    # update
+    assert isinstance(formdata.evolution[-1].parts[1], ContentSnapshotPart)
+    assert formdata.evolution[-1].parts[1].formdef_type == 'formdef'
+    assert formdata.evolution[-1].parts[1].formdef_id == str(formdef.id)
+    assert formdata.evolution[-1].parts[1].old_data == {'0': 'foo@localhost'}
+    assert formdata.evolution[-1].parts[1].new_data == {'0': 'bar@localhost'}
+    dt2 = formdata.evolution[-1].parts[1].datetime
+    assert dt2 > dt1
+
+
+def test_api_card_import_csv(pub, user, access, role):
+    app = get_app(pub)
+    app.set_authorization(('Basic', ('test', '12345')))
+
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'test'
+    carddef.fields = [
+        fields.StringField(id='0', label='foobar', varname='foo'),
+        fields.StringField(id='1', label='foobar2', varname='foo2'),
+    ]
+    carddef.workflow_roles = {'_viewer': role.id}
+    carddef.backoffice_submission_roles = [role.id]
+    carddef.digest_templates = {'default': 'bla {{ form_var_foo }} xxx'}
+    carddef.store()
+
+    carddef.data_class().wipe()
+
+    resp = app.put(
+        '/api/cards/test/import-csv',
+        params=b'foobar;foobar2\nfirst entry;plop\n',
+        headers={'content-type': 'text/csv'},
+    )
+    assert resp.json == {'err': 0}
+    assert carddef.data_class().count() == 1
+    carddata = carddef.data_class().select()[0]
+    assert isinstance(carddata.evolution[0].parts[0], ContentSnapshotPart)
+    assert carddata.evolution[0].parts[0].formdef_type == 'carddef'
+    assert carddata.evolution[0].parts[0].formdef_id == str(carddef.id)
+    assert carddata.evolution[0].parts[0].old_data == {}
+    assert carddata.evolution[0].parts[0].new_data == {'0': 'first entry', '1': 'plop'}
+
+
+def test_api_card_import_json(pub, user, access, role):
+    app = get_app(pub)
+    app.set_authorization(('Basic', ('test', '12345')))
+
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'test'
+    carddef.fields = [
+        fields.StringField(id='0', label='foobar', varname='foo'),
+    ]
+    carddef.workflow_roles = {'_viewer': role.id}
+    carddef.backoffice_submission_roles = [role.id]
+    carddef.store()
+
+    carddef.data_class().wipe()
+
+    data = {
+        'data': [
+            {
+                'fields': {
+                    'foo': 'bar',
+                }
+            },
+        ]
+    }
+    resp = app.put_json(
+        '/api/cards/test/import-json',
+        data,
+    )
+    assert resp.json == {'err': 0}
+    assert carddef.data_class().count() == 1
+    carddata = carddef.data_class().select()[0]
+    assert isinstance(carddata.evolution[0].parts[0], ContentSnapshotPart)
+    assert carddata.evolution[0].parts[0].formdef_type == 'carddef'
+    assert carddata.evolution[0].parts[0].formdef_id == str(carddef.id)
+    assert carddata.evolution[0].parts[0].old_data == {}
+    assert carddata.evolution[0].parts[0].new_data == {'0': 'bar'}
+
+
+def test_api_card_submit(pub, user, access, role):
+    app = get_app(pub)
+    app.set_authorization(('Basic', ('test', '12345')))
+
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'test'
+    carddef.fields = [
+        fields.StringField(id='1', label='foobar', varname='foobar'),
+    ]
+    carddef.backoffice_submission_roles = [role.id]
+    carddef.store()
+    data_class = carddef.data_class()
+
+    payload = {
+        'data': {
+            'foobar': 'xxx',
+        }
+    }
+    resp = app.post_json(
+        '/api/cards/test/submit',
+        payload,
+    )
+    assert resp.json['err'] == 0
+    carddata = data_class.get(resp.json['data']['id'])
+    assert isinstance(carddata.evolution[0].parts[0], ContentSnapshotPart)
+    assert carddata.evolution[0].parts[0].formdef_type == 'carddef'
+    assert carddata.evolution[0].parts[0].formdef_id == str(carddef.id)
+    assert carddata.evolution[0].parts[0].old_data == {}
+    assert carddata.evolution[0].parts[0].new_data == {'1': 'xxx'}
diff --git a/tests/workflow/test_all.py b/tests/workflow/test_all.py
index f21912d03..9009344f1 100644
--- a/tests/workflow/test_all.py
+++ b/tests/workflow/test_all.py
@@ -73,6 +73,7 @@ from wcs.wf.wscall import WebserviceCallStatusItem
 from wcs.workflows import (
     AbortActionException,
     AttachmentEvolutionPart,
+    ContentSnapshotPart,
     Workflow,
     WorkflowBackofficeFieldsFormDef,
     WorkflowCriticalityLevel,
@@ -1343,13 +1344,16 @@ def test_register_comment_with_attachment_file(pub):
         assert len(subdir) == 4
         assert len(os.listdir(os.path.join(get_publisher().app_dir, 'attachments', subdir))) == 1
 
-    assert len(formdata.evolution[-1].parts) == 2
-    assert isinstance(formdata.evolution[-1].parts[0], AttachmentEvolutionPart)
-    assert formdata.evolution[-1].parts[0].orig_filename == upload.orig_filename
+    assert len(formdata.evolution[-1].parts) == 4
+    assert isinstance(formdata.evolution[-1].parts[0], ContentSnapshotPart)
+    assert isinstance(formdata.evolution[-1].parts[1], ContentSnapshotPart)
 
-    assert isinstance(formdata.evolution[-1].parts[1], JournalEvolutionPart)
-    assert len(formdata.evolution[-1].parts[1].content) > 0
-    comment_view = str(formdata.evolution[-1].parts[1].view())
+    assert isinstance(formdata.evolution[-1].parts[2], AttachmentEvolutionPart)
+    assert formdata.evolution[-1].parts[2].orig_filename == upload.orig_filename
+
+    assert isinstance(formdata.evolution[-1].parts[3], JournalEvolutionPart)
+    assert len(formdata.evolution[-1].parts[3].content) > 0
+    comment_view = str(formdata.evolution[-1].parts[3].view())
     assert comment_view == '<p>%s</p>' % comment_text
 
     if os.path.exists(os.path.join(get_publisher().app_dir, 'attachments')):
@@ -1422,13 +1426,13 @@ def test_register_comment_to(pub):
     register_commenter.comment = 'all'
     register_commenter.to = None
     register_commenter.perform(formdata)
-    assert len(formdata.evolution[-1].parts) == 1
+    assert len(formdata.evolution[-1].parts) == 2
     assert display_parts() == ['<p>all</p>']
 
     register_commenter.comment = 'to-role'
     register_commenter.to = [role.id]
     register_commenter.perform(formdata)
-    assert len(formdata.evolution[-1].parts) == 2
+    assert len(formdata.evolution[-1].parts) == 3
     assert len(display_parts()) == 1
     pub._request._user = user
     assert display_parts() == ['<p>all</p>']
@@ -1439,7 +1443,7 @@ def test_register_comment_to(pub):
     register_commenter.comment = 'to-submitter'
     register_commenter.to = ['_submitter']
     register_commenter.perform(formdata)
-    assert len(formdata.evolution[-1].parts) == 3
+    assert len(formdata.evolution[-1].parts) == 4
     assert display_parts() == ['<p>all</p>']
     formdata.user_id = user.id
     assert display_parts() == ['<p>all</p>', '<p>to-submitter</p>']
@@ -1447,7 +1451,7 @@ def test_register_comment_to(pub):
     register_commenter.comment = 'to-role-or-submitter'
     register_commenter.to = [role.id, '_submitter']
     register_commenter.perform(formdata)
-    assert len(formdata.evolution[-1].parts) == 4
+    assert len(formdata.evolution[-1].parts) == 5
     assert display_parts() == ['<p>all</p>', '<p>to-submitter</p>', '<p>to-role-or-submitter</p>']
     formdata.user_id = None
     assert display_parts() == ['<p>all</p>']
@@ -1464,7 +1468,7 @@ def test_register_comment_to(pub):
     register_commenter.comment = 'd1'
     register_commenter.to = [role2.id]
     register_commenter.perform(formdata)
-    assert len(formdata.evolution[-1].parts) == 5
+    assert len(formdata.evolution[-1].parts) == 6
     assert display_parts() == [
         '<p>all</p>',
         '<p>to-role</p>',
@@ -1476,7 +1480,7 @@ def test_register_comment_to(pub):
     register_commenter2.to = [role.id, '_submitter']
     user.roles = [role.id, role2.id]
     register_commenter2.perform(formdata)
-    assert len(formdata.evolution[-1].parts) == 6
+    assert len(formdata.evolution[-1].parts) == 7
     assert '<p>d1</p>' in [str(x) for x in display_parts()]
     assert '<p>d2</p>' in [str(x) for x in display_parts()]
 
@@ -1547,7 +1551,7 @@ def test_register_comment_to_with_attachment(pub):
     register_commenter.to = [role.id, '_submitter']
     register_commenter.perform(formdata)
 
-    assert len(formdata.evolution[-1].parts) == 8
+    assert len(formdata.evolution[-1].parts) == 9
 
     assert user.roles == []
     assert len(display_parts()) == 2
@@ -2192,7 +2196,7 @@ def test_webservice_call_store_in_backoffice_filefield(http_requests, pub):
     assert fbo1.get_content().startswith(b'<?xml')
     # nothing else is stored
     assert formdata.workflow_data is None
-    assert not formdata.evolution[-1].parts
+    assert isinstance(formdata.evolution[-1].parts[0], ContentSnapshotPart)
 
     # store in backoffice file field + varname
     formdata = formdef.data_class()()
@@ -3673,7 +3677,7 @@ def test_export_to_model_image(pub, template_name):
     item.perform(formdata)
 
     assert formdata.evolution[-1].parts[-1].base_filename == 'template.odt'
-    with zipfile.ZipFile(formdata.evolution[-1].parts[0].get_file_path(), mode='r') as zfile:
+    with zipfile.ZipFile(formdata.evolution[-1].parts[-1].get_file_path(), mode='r') as zfile:
         zinfo = zfile.getinfo('Pictures/10000000000000320000003276E9D46581B55C88.jpg')
     # check the image has been replaced by the one from the formdata
     assert zinfo.file_size == len(image_data)
@@ -3688,7 +3692,7 @@ def test_export_to_model_image(pub, template_name):
 
         item.perform(formdata)
 
-        with zipfile.ZipFile(formdata.evolution[-1].parts[0].get_file_path(), mode='r') as zfile:
+        with zipfile.ZipFile(formdata.evolution[-1].parts[-1].get_file_path(), mode='r') as zfile:
             zinfo = zfile.getinfo('Pictures/10000000000000320000003276E9D46581B55C88.jpg')
         # check the original image has been left
         assert zinfo.file_size == 580
@@ -3732,7 +3736,7 @@ def test_export_to_model_qrcode(pub):
     item.perform(formdata)
 
     assert formdata.evolution[-1].parts[-1].base_filename == 'template.odt'
-    with zipfile.ZipFile(formdata.evolution[-1].parts[0].get_file_path(), mode='r') as zfile:
+    with zipfile.ZipFile(formdata.evolution[-1].parts[-1].get_file_path(), mode='r') as zfile:
         # base template use a jpg images and export_to_model does not rename it
         # event when content is PNG, but it still works inside LibreOffice
         # which ignores the filename extension.
@@ -3839,7 +3843,7 @@ def test_export_to_model_django_template(pub):
     item.convert_to_pdf = False
     item.perform(formdata)
 
-    with open(formdata.evolution[0].parts[0].get_file_path(), 'rb') as fd:
+    with open(formdata.evolution[0].parts[-1].get_file_path(), 'rb') as fd:
         with zipfile.ZipFile(fd) as zout:
             new_content = zout.read('content.xml')
     assert b'>foo-export-to-template-with-django<' in new_content
@@ -3848,7 +3852,7 @@ def test_export_to_model_django_template(pub):
     formdef.store()
     item.perform(formdata)
 
-    with open(formdata.evolution[0].parts[1].get_file_path(), 'rb') as fd:
+    with open(formdata.evolution[0].parts[-1].get_file_path(), 'rb') as fd:
         with zipfile.ZipFile(fd) as zout:
             new_content = zout.read('content.xml')
     assert b'>Name with a \' simple quote<' in new_content
@@ -3857,7 +3861,7 @@ def test_export_to_model_django_template(pub):
     formdef.store()
     item.perform(formdata)
 
-    with open(formdata.evolution[0].parts[2].get_file_path(), 'rb') as fd:
+    with open(formdata.evolution[0].parts[-1].get_file_path(), 'rb') as fd:
         with zipfile.ZipFile(fd) as zout:
             new_content = zout.read('content.xml')
     assert b'>A &lt;&gt; name<' in new_content
@@ -3994,7 +3998,7 @@ def test_export_to_model_form_details_section(pub, filename):
     item.convert_to_pdf = False
     item.perform(formdata)
 
-    with open(formdata.evolution[0].parts[0].get_file_path(), 'rb') as fd:
+    with open(formdata.evolution[0].parts[-1].get_file_path(), 'rb') as fd:
         with zipfile.ZipFile(fd) as zout:
             new_content = force_text(zout.read('content.xml'))
     # section content has been removed
@@ -4018,7 +4022,7 @@ def test_export_to_model_form_details_section(pub, filename):
     assert 'XfooY, Xfoo2Y' in new_content
 
     if filename == 'template-form-details-no-styles.odt':
-        with open(formdata.evolution[0].parts[0].get_file_path(), 'rb') as fd:
+        with open(formdata.evolution[0].parts[-1].get_file_path(), 'rb') as fd:
             with zipfile.ZipFile(fd) as zout:
                 new_styles = force_text(zout.read('styles.xml'))
         assert 'Field_20_Label' in new_styles
@@ -4964,6 +4968,7 @@ def test_call_external_workflow_with_parent_object(pub):
     # carddef workflow, with global action to increment a counter in its
     # backoffice fields.
     carddef_wf = Workflow(name='Carddef Workflow')
+    carddef_wf.add_status('New')
     carddef_wf.backoffice_fields_formdef = WorkflowBackofficeFieldsFormDef(carddef_wf)
     carddef_wf.backoffice_fields_formdef.fields = [
         StringField(id='bo0', varname='bo', type='string', label='bo variable'),
@@ -4988,6 +4993,7 @@ def test_call_external_workflow_with_parent_object(pub):
     # and sample carddata
     carddata = carddef.data_class()()
     carddata.data = {'0': 'Text'}
+    carddata.just_created()
     carddata.store()
 
     # formdef workflow that will trigger the global action
@@ -5048,6 +5054,7 @@ def test_call_external_workflow_use_caller_variable(pub):
 
     # carddef workflow, with global action to set a value in a backoffice field
     carddef_wf = Workflow(name='Carddef Workflow')
+    carddef_wf.add_status('New')
     carddef_wf.backoffice_fields_formdef = WorkflowBackofficeFieldsFormDef(carddef_wf)
     carddef_wf.backoffice_fields_formdef.fields = [
         StringField(id='bo0', varname='bo', type='string', label='bo variable'),
@@ -5072,6 +5079,7 @@ def test_call_external_workflow_use_caller_variable(pub):
     # and sample carddata
     carddata = carddef.data_class()()
     carddata.data = {'0': 'Text'}
+    carddata.just_created()
     carddata.store()
 
     # formdef workflow that will trigger the global action
@@ -5149,6 +5157,7 @@ def test_call_external_workflow_manual_targeting(pub):
     for i in range(1, 4):
         carddata = carddef.data_class()()
         carddata.data = {'0': 'Text %s' % i}
+        carddata.just_created()
         carddata.store()
 
     # formdef workflow that will trigger the global action
diff --git a/wcs/api.py b/wcs/api.py
index bebb1672f..b2f6dde73 100644
--- a/wcs/api.py
+++ b/wcs/api.py
@@ -14,6 +14,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
+import copy
 import datetime
 import json
 import re
@@ -40,6 +41,7 @@ from wcs.forms.common import FormStatusPage
 from wcs.qommon import get_cfg
 from wcs.qommon.afterjobs import AfterJob
 from wcs.roles import logged_users_role
+from wcs.workflows import ContentSnapshotPart
 
 from .backoffice.data_management import CardPage as BackofficeCardPage
 from .backoffice.management import FormPage as BackofficeFormPage
@@ -174,12 +176,16 @@ class ApiFormdataPage(FormStatusPage):
             if 'data' not in json_input:
                 raise RequestError('missing data entry in payload')
             data = posted_json_data_to_formdata_data(self.formdef, json_input['data'])
+            old_data = copy.deepcopy(self.formdata.data)
             self.formdata.data.update(data)
             self.formdata.store()
 
             if item.status:
                 self.formdata.jump_status(item.status)
                 self.formdata.perform_workflow(event=('api-post-edit-action', item.id))
+            evo = self.formdata.evolution[-1]
+            evo.add_part(ContentSnapshotPart(formdata=self.formdata, old_data=old_data))
+            self.formdata.store()
 
             return json.dumps({'err': 0, 'data': {'id': str(self.formdata.id)}})
 
diff --git a/wcs/formdata.py b/wcs/formdata.py
index 0ac197379..100acaa39 100644
--- a/wcs/formdata.py
+++ b/wcs/formdata.py
@@ -492,6 +492,8 @@ class FormData(StorableObject):
             return None
 
     def just_created(self):
+        from wcs.workflows import ContentSnapshotPart
+
         self.receipt_time = time.localtime()
         self.status = 'wf-%s' % self.formdef.workflow.possible_status[0].id
         # we add the initial status to the history, this makes it more readable
@@ -502,6 +504,7 @@ class FormData(StorableObject):
         evo.time = self.receipt_time
         evo.status = self.status
         self.evolution = [evo]
+        evo.add_part(ContentSnapshotPart(formdata=self, old_data={}))
 
     def set_auto_fields(self, *args, **kwargs):
         fields = {}
@@ -760,7 +763,7 @@ class FormData(StorableObject):
             return None
 
     def jump_status(self, status_id, user_id=None):
-        from wcs.workflows import ActionsTracingEvolutionPart
+        from wcs.workflows import ActionsTracingEvolutionPart, ContentSnapshotPart
 
         if status_id == '_previous':
             previous_status = self.pop_previous_marked_status()
@@ -777,7 +780,9 @@ class FormData(StorableObject):
             and self.evolution[-1].status == status
             and not self.evolution[-1].comment
             and not [
-                x for x in self.evolution[-1].parts or [] if not isinstance(x, ActionsTracingEvolutionPart)
+                x
+                for x in self.evolution[-1].parts or []
+                if not isinstance(x, (ActionsTracingEvolutionPart, ContentSnapshotPart))
             ]
         ):
             # if status do not change and last evolution is empty,
diff --git a/wcs/forms/root.py b/wcs/forms/root.py
index f2e964ec0..479d9d78a 100644
--- a/wcs/forms/root.py
+++ b/wcs/forms/root.py
@@ -43,7 +43,7 @@ from wcs.qommon.form import get_selection_error_text
 from wcs.qommon.storage import Equal, NothingToUpdate
 from wcs.roles import logged_users_role
 from wcs.variables import LazyFormDef
-from wcs.workflows import Workflow, WorkflowBackofficeFieldsFormDef, WorkflowStatusItem
+from wcs.workflows import ContentSnapshotPart, Workflow, WorkflowBackofficeFieldsFormDef, WorkflowStatusItem
 
 from ..qommon import _, emails, errors, get_cfg, misc, ngettext, template
 from ..qommon.admin.emails import EmailsDirectory
@@ -1647,10 +1647,13 @@ class FormPage(FormdefDirectoryBase, FormTemplateMixin):
         for k, v in self.edited_data.data.items():
             if k.startswith(WorkflowBackofficeFieldsFormDef.field_prefix):
                 new_data[k] = v
+        old_data = copy.deepcopy(self.edited_data.data)
         self.edited_data.data = new_data
         if getattr(self, 'selected_user_id', None):
             # user selection in backoffice
             self.edited_data.user_id = self.selected_user_id
+        evo = self.edited_data.evolution[-1]
+        evo.add_part(ContentSnapshotPart(formdata=self.edited_data, old_data=old_data))
         self.edited_data.store()
         # remove previous vars and formdata from substitution variables
         self.clean_submission_context()
diff --git a/wcs/wf/backoffice_fields.py b/wcs/wf/backoffice_fields.py
index 86c5f1b09..505ef1832 100644
--- a/wcs/wf/backoffice_fields.py
+++ b/wcs/wf/backoffice_fields.py
@@ -14,6 +14,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
+import copy
 import xml.etree.ElementTree as ET
 
 from quixote import get_publisher
@@ -21,7 +22,7 @@ from quixote.html import htmltext
 
 from wcs.fields import SetValueError, WidgetField
 from wcs.wf.profile import FieldNode
-from wcs.workflows import WorkflowStatusItem, register_item_class
+from wcs.workflows import ContentSnapshotPart, WorkflowStatusItem, register_item_class
 
 from ..qommon import _
 from ..qommon.form import (
@@ -137,6 +138,8 @@ class SetBackofficeFieldsWorkflowStatusItem(WorkflowStatusItem):
     def perform(self, formdata):
         if not self.fields:
             return
+
+        old_data = copy.deepcopy(formdata.data)
         for field in self.fields:
             try:
                 formdef_field = [
@@ -203,6 +206,10 @@ class SetBackofficeFieldsWorkflowStatusItem(WorkflowStatusItem):
             # and backoffice field values can be used in subsequent fields.
             formdata.store()
 
+        evo = formdata.evolution[-1]
+        evo.add_part(ContentSnapshotPart(formdata=formdata, old_data=old_data))
+        formdata.store()
+
     def fields_export_to_xml(self, item, charset, include_id=False):
         if not self.fields:
             return
diff --git a/wcs/wf/edit_carddata.py b/wcs/wf/edit_carddata.py
index 9152d3033..1228f66a7 100644
--- a/wcs/wf/edit_carddata.py
+++ b/wcs/wf/edit_carddata.py
@@ -14,12 +14,16 @@
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
+import copy
+import time
+
 from quixote import get_publisher
 
+from wcs.formdata import Evolution
 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
+from wcs.workflows import ContentSnapshotPart, register_item_class
 
 
 class EditCarddataWorkflowStatusItem(CreateCarddataWorkflowStatusItem, ExternalWorkflowGlobalAction):
@@ -49,8 +53,14 @@ class EditCarddataWorkflowStatusItem(CreateCarddataWorkflowStatusItem, ExternalW
         formdata.store()
 
         for target_data in self.iter_target_datas(formdata, carddef):
+            old_data = copy.deepcopy(target_data.data)
             self.apply_mappings(dest=target_data, src=formdata)
             with get_publisher().substitutions.freeze():
+                evo = Evolution()
+                evo.time = time.localtime()
+                evo.status = target_data.status
+                target_data.evolution.append(evo)
+                evo.add_part(ContentSnapshotPart(formdata=target_data, old_data=old_data))
                 target_data.store()
 
         # update local object as it may have modified itself
diff --git a/wcs/workflows.py b/wcs/workflows.py
index a8eed8054..6fc19a9f7 100644
--- a/wcs/workflows.py
+++ b/wcs/workflows.py
@@ -16,6 +16,7 @@
 
 import base64
 import collections
+import copy
 import datetime
 import glob
 import itertools
@@ -27,6 +28,7 @@ import xml.etree.ElementTree as ET
 from importlib import import_module
 
 from django.utils.encoding import force_text
+from django.utils.timezone import now
 from quixote import get_publisher, get_request, get_response
 from quixote.html import TemplateIO, htmltext
 
@@ -452,6 +454,15 @@ class ActionsTracingEvolutionPart(EvolutionPart):
         return real_action
 
 
+class ContentSnapshotPart(EvolutionPart):
+    def __init__(self, formdata, old_data):
+        self.datetime = now()
+        self.formdef_type = formdata.formdef.xml_root_node
+        self.formdef_id = formdata.formdef.id
+        self.old_data = old_data
+        self.new_data = copy.deepcopy(formdata.data)
+
+
 class DuplicateGlobalActionNameError(Exception):
     pass
 
-- 
2.35.1

