From c346bb9f74f6cfcdc7e69347096acd50e85c0d0a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 14 Oct 2022 16:57:21 +0200
Subject: [PATCH] api: get formdata from history (#70271)

---
 tests/test_content_snapshots.py | 144 ++++++++++++++++++++++++++++++++
 wcs/formdata.py                 |  36 +++++++-
 wcs/forms/common.py             |  10 ++-
 3 files changed, 183 insertions(+), 7 deletions(-)

diff --git a/tests/test_content_snapshots.py b/tests/test_content_snapshots.py
index 90bdbd35b..e0ba7db1f 100644
--- a/tests/test_content_snapshots.py
+++ b/tests/test_content_snapshots.py
@@ -1,5 +1,7 @@
+import datetime
 import json
 import os
+import time
 
 import pytest
 from webtest import Upload
@@ -7,6 +9,7 @@ from webtest import Upload
 from wcs import fields
 from wcs.api_access import ApiAccess
 from wcs.carddef import CardDef
+from wcs.formdata import Evolution
 from wcs.formdef import FormDef
 from wcs.qommon.http_request import HTTPRequest
 from wcs.qommon.ident.password_accounts import PasswordAccount
@@ -716,3 +719,144 @@ def test_api_card_submit(pub, user, access, role):
     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'}
+
+
+def test_api_formdata_at(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 = Workflow(name='foo')
+    workflow.possible_status = Workflow.get_default_workflow().possible_status[:]
+    workflow.backoffice_fields_formdef = WorkflowBackofficeFieldsFormDef(workflow)
+    workflow.backoffice_fields_formdef.fields = [
+        fields.StringField(id='bo1', label='bo field 1', type='string', varname='plop'),
+    ]
+    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',
+        'bo1': 'bar',
+    }
+    formdata.just_created()
+    formdata.evolution[0].time = datetime.datetime(2022, 1, 2, 3, 4).timetuple()
+    formdata.store()
+
+    def get_evo_and_parts(formdata):
+        for evo in formdata.evolution:
+            for part in evo.parts or []:
+                if isinstance(part, ContentSnapshotPart):
+                    yield time.strftime('%Y-%m-%d %H:%M', evo.time), part.old_data, part.new_data
+
+    assert list(get_evo_and_parts(formdata)) == [('2022-01-02 03:04', {}, {'0': 'foo', 'bo1': 'bar'})]
+    resp = app.get('/api/forms/test/%s/' % formdata.id)
+    assert resp.json['fields'] == {'foobar': 'foo'}
+    assert resp.json['workflow']['fields'] == {'plop': 'bar'}
+
+    # wrong format
+    resp = app.get('/api/forms/test/%s/?at=bad-format' % formdata.id, status=400)
+    assert resp.json['err_desc'] == 'Invalid value "bad-format" for "at"'
+
+    # before formdata creation
+    resp = app.get('/api/forms/test/%s/?at=2022-01-02' % formdata.id, status=400)
+    assert resp.json['err_desc'] == 'No data found for "2022-01-02" date'
+    resp = app.get('/api/forms/test/%s/?at=2022-01-02 03:04' % formdata.id, status=400)
+    assert resp.json['err_desc'] == 'No data found for "2022-01-02 03:04" date'
+
+    # no ContentSnapshotPart (legacy formdata)
+    formdata.evolution[0].parts = []
+    formdata.store()
+    assert list(get_evo_and_parts(formdata)) == []
+
+    # add evolutions with ContentSnapshotPart
+    evo = formdata.evolution[0]
+    part = ContentSnapshotPart(formdata=formdata, old_data={})
+    part.new_data = {'0': 'bar', 'bo1': 'foo'}
+    evo.add_part(part)
+
+    evo = Evolution()
+    evo.time = datetime.datetime(2022, 1, 2, 3, 5).timetuple()
+    evo.status = formdata.status
+    part = ContentSnapshotPart(formdata=formdata, old_data={'0': 'bar', 'bo1': 'foo'})
+    part.new_data = {'0': 'baz', 'bo1': 'foo'}
+    evo.add_part(part)
+    formdata.evolution.append(evo)
+
+    evo = Evolution()
+    evo.time = datetime.datetime(2022, 1, 4, 5, 6).timetuple()
+    evo.status = formdata.status
+    part = ContentSnapshotPart(formdata=formdata, old_data={'0': 'baz', 'bo1': 'foo'})
+    part.new_data = {'0': 'foooo', '1': 'unknown', 'bo1': 'foo'}
+    evo.add_part(part)
+    formdata.evolution.append(evo)
+
+    evo = Evolution()
+    evo.time = datetime.datetime(2022, 1, 5, 6, 7).timetuple()
+    evo.status = formdata.status
+    part = ContentSnapshotPart(formdata=formdata, old_data={'0': 'foooo', '1': 'unknown', 'bo1': 'foo'})
+    part.new_data = {'0': 'fooo', 'bo1': 'foo'}
+    evo.add_part(part)
+    part = ContentSnapshotPart(formdata=formdata, old_data={'0': 'fooo', 'bo1': 'foo'})
+    part.new_data = {'0': 'foo', 'bo1': 'bar'}
+    evo.add_part(part)
+    formdata.evolution.append(evo)
+
+    formdata._store_all_evolution = True
+    formdata.store()
+    assert list(get_evo_and_parts(formdata)) == [
+        ('2022-01-02 03:04', {}, {'0': 'bar', 'bo1': 'foo'}),
+        ('2022-01-02 03:05', {'0': 'bar', 'bo1': 'foo'}, {'0': 'baz', 'bo1': 'foo'}),
+        ('2022-01-04 05:06', {'0': 'baz', 'bo1': 'foo'}, {'0': 'foooo', '1': 'unknown', 'bo1': 'foo'}),
+        ('2022-01-05 06:07', {'0': 'foooo', '1': 'unknown', 'bo1': 'foo'}, {'0': 'fooo', 'bo1': 'foo'}),
+        ('2022-01-05 06:07', {'0': 'fooo', 'bo1': 'foo'}, {'0': 'foo', 'bo1': 'bar'}),
+    ]
+
+    resp = app.get('/api/forms/test/%s/?at=2022-01-03' % formdata.id)
+    assert resp.json['fields'] == {'foobar': 'baz'}
+    assert resp.json['workflow']['fields'] == {'plop': 'foo'}
+
+    resp = app.get('/api/forms/test/%s/?at=03/01/2022' % formdata.id)
+    assert resp.json['fields'] == {'foobar': 'baz'}
+    assert resp.json['workflow']['fields'] == {'plop': 'foo'}
+
+    resp = app.get('/api/forms/test/%s/?at=2022-01-02 03:05' % formdata.id)
+    assert resp.json['fields'] == {'foobar': 'bar'}
+    assert resp.json['workflow']['fields'] == {'plop': 'foo'}
+
+    resp = app.get('/api/forms/test/%s/?at=2022-01-02 03:06' % formdata.id)
+    assert resp.json['fields'] == {'foobar': 'baz'}
+    assert resp.json['workflow']['fields'] == {'plop': 'foo'}
+
+    resp = app.get('/api/forms/test/%s/?at=2022-01-04' % formdata.id)
+    assert resp.json['fields'] == {'foobar': 'baz'}
+    assert resp.json['workflow']['fields'] == {'plop': 'foo'}
+
+    resp = app.get('/api/forms/test/%s/?at=2022-01-05' % formdata.id)
+    assert resp.json['fields'] == {'foobar': 'foooo'}
+    assert resp.json['workflow']['fields'] == {'plop': 'foo'}
+
+    resp = app.get('/api/forms/test/%s/?at=2022-01-05 06:07' % formdata.id)
+    assert resp.json['fields'] == {'foobar': 'foooo'}
+    assert resp.json['workflow']['fields'] == {'plop': 'foo'}
+
+    resp = app.get('/api/forms/test/%s/?at=2022-01-05 06:08' % formdata.id)
+    assert resp.json['fields'] == {'foobar': 'foo'}
+    assert resp.json['workflow']['fields'] == {'plop': 'bar'}
+
+    resp = app.get('/api/forms/test/%s/?at=2022-01-06' % formdata.id)
+    assert resp.json['fields'] == {'foobar': 'foo'}
+    assert resp.json['workflow']['fields'] == {'plop': 'bar'}
+
+    resp = app.get('/api/forms/test/%s/?at=2022-01-07' % formdata.id)
+    assert resp.json['fields'] == {'foobar': 'foo'}
+    assert resp.json['workflow']['fields'] == {'plop': 'bar'}
diff --git a/wcs/formdata.py b/wcs/formdata.py
index b93168cb8..5f3d039a2 100644
--- a/wcs/formdata.py
+++ b/wcs/formdata.py
@@ -24,6 +24,7 @@ import sys
 import time
 
 from quixote import get_publisher, get_request, get_session
+from quixote.errors import RequestError
 from quixote.html import htmltext
 from quixote.http_request import Upload
 
@@ -1332,9 +1333,9 @@ class FormData(StorableObject):
                     store_dict[store_key + '_structured'] = data.get('%s_structured' % field.id)
         return new_data
 
-    def get_json_dict(self, fields, include_files=True, anonymise=False, include_unnamed_fields=False):
+    def get_json_dict(self, data, fields, include_files=True, anonymise=False, include_unnamed_fields=False):
         return self.get_json_data_dict(
-            self.data,
+            data,
             fields,
             formdata=self,
             include_files=include_files,
@@ -1353,7 +1354,9 @@ class FormData(StorableObject):
         include_evolution=True,
         include_roles=True,
         include_unnamed_fields=False,
+        values_at=None,
     ):
+        # noqa pylint: disable=too-many-arguments
         data = {}
         data['id'] = str(self.id)
         data['digests'] = self.digests
@@ -1379,7 +1382,29 @@ class FormData(StorableObject):
 
             data['user'] = user.get_json_export_dict(full=isinstance(self.formdef, CardDef))
 
+        _data = self.data
+        if values_at:
+            from wcs.workflows import ContentSnapshotPart
+
+            try:
+                values_at_tuple = misc.get_as_datetime(values_at).timetuple()
+            except ValueError:
+                raise RequestError('Invalid value "%s" for "at"' % values_at)
+            matching_part = None
+            for evo in reversed(self.evolution or []):
+                for part in reversed(evo.parts or []):
+                    if isinstance(part, ContentSnapshotPart):
+                        if evo.time < values_at_tuple:
+                            matching_part = part
+                        break
+                if matching_part:
+                    break
+            if not matching_part:
+                raise RequestError('No data found for "%s" date' % values_at)
+            _data = matching_part.new_data
+
         data['fields'] = self.get_json_dict(
+            _data,
             self.formdef.fields,
             include_files=include_files,
             anonymise=anonymise,
@@ -1398,6 +1423,7 @@ class FormData(StorableObject):
             data['workflow']['data'] = self.workflow_data
         if self.formdef.workflow.get_backoffice_fields():
             data['workflow']['fields'] = self.get_json_dict(
+                _data,
                 self.formdef.workflow.get_backoffice_fields(),
                 include_files=include_files,
                 anonymise=anonymise,
@@ -1472,8 +1498,10 @@ class FormData(StorableObject):
 
         return data
 
-    def export_to_json(self, include_files=True, anonymise=False):
-        data = self.get_json_export_dict(include_files=include_files, anonymise=anonymise)
+    def export_to_json(self, include_files=True, anonymise=False, values_at=None):
+        data = self.get_json_export_dict(
+            include_files=include_files, anonymise=anonymise, values_at=values_at
+        )
         return json.dumps(data, cls=misc.JSONEncoder)
 
     def get_object_key(self):
diff --git a/wcs/forms/common.py b/wcs/forms/common.py
index ede5e8f10..fc84cda68 100644
--- a/wcs/forms/common.py
+++ b/wcs/forms/common.py
@@ -211,7 +211,9 @@ class FormStatusPage(Directory, FormTemplateMixin):
                 raise errors.TraversalError(_('ID not available in filtered view'))
 
         return self.export_to_json(
-            anonymise=anonymise, include_files=get_query_flag('include-files-content', default=True)
+            anonymise=anonymise,
+            include_files=get_query_flag('include-files-content', default=True),
+            values_at=get_request().form.get('at'),
         )
 
     def tempfile(self):
@@ -421,9 +423,11 @@ class FormStatusPage(Directory, FormTemplateMixin):
             response.content_type = 'text/plain'
             return "Your browser should redirect you"
 
-    def export_to_json(self, include_files=True, anonymise=False):
+    def export_to_json(self, include_files=True, anonymise=False, values_at=None):
         get_response().set_content_type('application/json')
-        return self.filled.export_to_json(include_files=include_files, anonymise=anonymise)
+        return self.filled.export_to_json(
+            include_files=include_files, anonymise=anonymise, values_at=values_at
+        )
 
     def history(self):
         if not self.filled.evolution:
-- 
2.35.1

