From 9707c97392014f92c225134aa04a21dafea63a8f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 16 Oct 2020 11:42:10 +0200
Subject: [PATCH] cards: add shared and datasource custom views to carddef
 export (#42571)

---
 tests/test_carddef.py        | 73 ++++++++++++++++++++++++++++++++++++
 tests/test_formdef_import.py | 64 +++++++++++++++++++++++++++++++
 tests/test_snapshots.py      | 39 ++++++++++++++++++-
 wcs/custom_views.py          | 65 +++++++++++++++++++++++++++++++-
 wcs/formdef.py               | 19 ++++++++++
 5 files changed, 257 insertions(+), 3 deletions(-)

diff --git a/tests/test_carddef.py b/tests/test_carddef.py
index c566439c..81158e4b 100644
--- a/tests/test_carddef.py
+++ b/tests/test_carddef.py
@@ -77,13 +77,86 @@ def test_xml_export_import(pub):
             data_source={'type': 'carddef:foo'})
     ]
     carddef.store()
+
+    # define also custom views
+    pub.custom_view_class.wipe()
+
+    custom_view = pub.custom_view_class()
+    custom_view.title = 'datasource card view'
+    custom_view.formdef = carddef
+    custom_view.columns = {'list': [{'id': 'id'}, {'id': 'time'}, {'id': 'status'}, {'id': '1'}, {'id': '2'}]}
+    custom_view.filters = {'filter': 'recorded', 'filter-1': 'on', 'filter-status': 'on', 'filter-1-value': 'a'}
+    custom_view.visibility = 'datasource'
+    custom_view.order_by = '-receipt_time'
+    custom_view.store()
+
+    custom_view = pub.custom_view_class()
+    custom_view.title = 'shared card view'
+    custom_view.formdef = carddef
+    custom_view.columns = {'list': [{'id': 'id'}, {'id': 'time'}, {'id': 'status'}]}
+    custom_view.filters = {'filter': 'done', 'filter-1': 'on', 'filter-status': 'on', 'filter-1-value': 'b'}
+    custom_view.visibility = 'any'
+    custom_view.order_by = 'receipt_time'
+    custom_view.store()
+
+    custom_view = pub.custom_view_class()
+    custom_view.title = 'private card view'
+    custom_view.formdef = carddef
+    custom_view.columns = {'list': [{'id': 'id'}]}
+    custom_view.filters = {}
+    custom_view.visibility = 'owner'
+    custom_view.usier_id = 42
+    custom_view.order_by = 'id'
+    custom_view.store()
+
     carddef_xml = carddef.export_to_xml()
     assert carddef_xml.tag == 'carddef'
     carddef.data_class().wipe()
+    pub.custom_view_class.wipe()
 
     carddef2 = CardDef.import_from_xml(BytesIO(ET.tostring(carddef_xml)))
     assert carddef2.name == 'foo'
     assert carddef2.fields[1].data_source == {'type': 'carddef:foo'}
+    assert carddef2._custom_views
+
+    custom_views = sorted(carddef2._custom_views, key=lambda a: a.visibility)
+    assert len(custom_views) == 2
+    assert custom_views[0].title == 'shared card view'
+    assert custom_views[0].slug == 'shared-card-view'
+    assert custom_views[0].columns == {'list': [{'id': 'id'}, {'id': 'time'}, {'id': 'status'}]}
+    assert custom_views[0].filters == {'filter': 'done', 'filter-1': 'on', 'filter-status': 'on', 'filter-1-value': 'b'}
+    assert custom_views[0].visibility == 'any'
+    assert custom_views[0].order_by == 'receipt_time'
+    assert custom_views[0].formdef_id is None
+    assert custom_views[0].formdef_type is None
+    assert custom_views[1].title == 'datasource card view'
+    assert custom_views[1].slug == 'datasource-card-view'
+    assert custom_views[1].columns == {'list': [{'id': 'id'}, {'id': 'time'}, {'id': 'status'}, {'id': '1'}, {'id': '2'}]}
+    assert custom_views[1].filters == {'filter': 'recorded', 'filter-1': 'on', 'filter-status': 'on', 'filter-1-value': 'a'}
+    assert custom_views[1].visibility == 'datasource'
+    assert custom_views[1].order_by == '-receipt_time'
+    assert custom_views[1].formdef_id is None
+    assert custom_views[1].formdef_type is None
+
+    carddef2.store()
+    custom_views = sorted(pub.custom_view_class.select(), key=lambda a: a.visibility)
+    assert len(custom_views) == 2
+    assert custom_views[0].title == 'shared card view'
+    assert custom_views[0].slug == 'shared-card-view'
+    assert custom_views[0].columns == {'list': [{'id': 'id'}, {'id': 'time'}, {'id': 'status'}]}
+    assert custom_views[0].filters == {'filter': 'done', 'filter-1': 'on', 'filter-status': 'on', 'filter-1-value': 'b'}
+    assert custom_views[0].visibility == 'any'
+    assert custom_views[0].order_by == 'receipt_time'
+    assert custom_views[0].formdef_id == carddef2.id
+    assert custom_views[0].formdef_type == 'carddef'
+    assert custom_views[1].title == 'datasource card view'
+    assert custom_views[1].slug == 'datasource-card-view'
+    assert custom_views[1].columns == {'list': [{'id': 'id'}, {'id': 'time'}, {'id': 'status'}, {'id': '1'}, {'id': '2'}]}
+    assert custom_views[1].filters == {'filter': 'recorded', 'filter-1': 'on', 'filter-status': 'on', 'filter-1-value': 'a'}
+    assert custom_views[1].visibility == 'datasource'
+    assert custom_views[1].order_by == '-receipt_time'
+    assert custom_views[1].formdef_id == carddef2.id
+    assert custom_views[1].formdef_type == 'carddef'
 
 
 def test_template_access(pub):
diff --git a/tests/test_formdef_import.py b/tests/test_formdef_import.py
index 7a1d355a..95bdffd4 100644
--- a/tests/test_formdef_import.py
+++ b/tests/test_formdef_import.py
@@ -673,3 +673,67 @@ def test_field_prefill():
     f2 = FormDef.import_from_xml_tree(formdef_xml)
     assert len(f2.fields) == len(formdef.fields)
     assert f2.fields[0].prefill == {'type': 'string', 'value': None}
+
+
+def test_custom_views():
+    formdef = FormDef()
+    formdef.name = 'foo'
+    formdef.fields = [
+        fields.StringField(id='1', label='Foo', type='string', varname='foo'),
+        fields.StringField(id='2', label='Bar', type='string', varname='bar'),
+    ]
+    formdef.store()
+
+    # define also custom views
+    pub.custom_view_class.wipe()
+
+    custom_view = pub.custom_view_class()
+    custom_view.title = 'shared form view'
+    custom_view.formdef = formdef
+    custom_view.columns = {'list': [{'id': 'id'}, {'id': 'time'}, {'id': 'status'}]}
+    custom_view.filters = {'filter': 'done', 'filter-1': 'on', 'filter-status': 'on', 'filter-1-value': 'b'}
+    custom_view.visibility = 'any'
+    custom_view.order_by = 'receipt_time'
+    custom_view.store()
+
+    custom_view = pub.custom_view_class()
+    custom_view.title = 'private form view'
+    custom_view.formdef = formdef
+    custom_view.columns = {'list': [{'id': 'id'}]}
+    custom_view.filters = {}
+    custom_view.visibility = 'owner'
+    custom_view.usier_id = 42
+    custom_view.order_by = 'id'
+    custom_view.store()
+
+    formdef_xml = formdef.export_to_xml()
+    assert formdef_xml.tag == 'formdef'
+    formdef.data_class().wipe()
+    pub.custom_view_class.wipe()
+
+    formdef2 = FormDef.import_from_xml(BytesIO(ET.tostring(formdef_xml)))
+    assert formdef2.name == 'foo'
+    assert formdef2._custom_views
+
+    custom_views = formdef2._custom_views
+    assert len(custom_views) == 1
+    assert custom_views[0].title == 'shared form view'
+    assert custom_views[0].slug == 'shared-form-view'
+    assert custom_views[0].columns == {'list': [{'id': 'id'}, {'id': 'time'}, {'id': 'status'}]}
+    assert custom_views[0].filters == {'filter': 'done', 'filter-1': 'on', 'filter-status': 'on', 'filter-1-value': 'b'}
+    assert custom_views[0].visibility == 'any'
+    assert custom_views[0].order_by == 'receipt_time'
+    assert custom_views[0].formdef_id is None
+    assert custom_views[0].formdef_type is None
+
+    formdef2.store()
+    custom_views = pub.custom_view_class.select()
+    assert len(custom_views) == 1
+    assert custom_views[0].title == 'shared form view'
+    assert custom_views[0].slug == 'shared-form-view'
+    assert custom_views[0].columns == {'list': [{'id': 'id'}, {'id': 'time'}, {'id': 'status'}]}
+    assert custom_views[0].filters == {'filter': 'done', 'filter-1': 'on', 'filter-status': 'on', 'filter-1-value': 'b'}
+    assert custom_views[0].visibility == 'any'
+    assert custom_views[0].order_by == 'receipt_time'
+    assert custom_views[0].formdef_id == formdef2.id
+    assert custom_views[0].formdef_type == 'formdef'
diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py
index a96a220b..b8cbb78d 100644
--- a/tests/test_snapshots.py
+++ b/tests/test_snapshots.py
@@ -242,6 +242,22 @@ def test_card_snapshot_browse(pub):
     carddef.fields = []
     carddef.store()
 
+    pub.custom_view_class.wipe()
+    custom_view = pub.custom_view_class()
+    custom_view.title = 'shared form view'
+    custom_view.formdef = carddef
+    custom_view.columns = {'list': [{'id': 'id'}]}
+    custom_view.filters = {}
+    custom_view.visibility = 'any'
+    custom_view.store()
+
+    # new version has custom views
+    carddef.name = 'test 1'
+    carddef.store()
+
+    # delete custom views
+    pub.custom_view_class.wipe()
+
     app = login(get_app(pub))
 
     resp = app.get('/backoffice/cards/%s/history/' % carddef.id)
@@ -250,6 +266,7 @@ def test_card_snapshot_browse(pub):
     assert 'This card model is readonly' in resp
     resp = resp.click('Geolocation')
     assert [x[0].name for x in resp.form.fields.values() if x[0].tag == 'button'] == ['cancel']
+    assert pub.custom_view_class.count() == 0  # custom views are not restore on preview
 
 
 def test_datasource_snapshot_browse(pub):
@@ -277,13 +294,31 @@ def test_form_snapshot_browse(pub, formdef_with_history):
     create_role()
     app = login(get_app(pub))
 
+    pub.custom_view_class.wipe()
+    custom_view = pub.custom_view_class()
+    custom_view.title = 'shared form view'
+    custom_view.formdef = formdef_with_history
+    custom_view.columns = {'list': [{'id': 'id'}]}
+    custom_view.filters = {}
+    custom_view.visibility = 'any'
+    custom_view.store()
+
+    # version 5 has custom views
+    formdef_with_history.name = 'testform 5'
+    formdef_with_history.description = 'this is a description (5)'
+    formdef_with_history.store()
+
+    # delete custom views
+    pub.custom_view_class.wipe()
+
     resp = app.get('/backoffice/forms/%s/history/' % formdef_with_history.id)
-    snapshot = pub.snapshot_class.select_object_history(formdef_with_history)[2]
+    snapshot = pub.snapshot_class.select_object_history(formdef_with_history)[0]
     resp = resp.click(href='%s/view/' % snapshot.id)
     assert 'This form is readonly' in resp
     resp = resp.click('Description')
-    assert resp.form['description'].value == 'this is a description (2)'
+    assert resp.form['description'].value == 'this is a description (5)'
     assert [x[0].name for x in resp.form.fields.values() if x[0].tag == 'button'] == ['cancel']
+    assert pub.custom_view_class.count() == 0  # custom views are not restore on preview
 
 
 def test_workflow_snapshot_browse(pub):
diff --git a/wcs/custom_views.py b/wcs/custom_views.py
index 26de1fbe..01635fb2 100644
--- a/wcs/custom_views.py
+++ b/wcs/custom_views.py
@@ -14,13 +14,17 @@
 # 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 xml.etree.ElementTree as ET
+
 from django.utils.six.moves.urllib import parse as urlparse
+from django.utils.encoding import force_text
 from quixote import get_publisher
 
 from wcs.carddef import CardDef
 from wcs.formdef import FormDef
 from wcs.qommon.storage import StorableObject, Equal
 from wcs.qommon.misc import simplify
+from .qommon.misc import xml_node_text
 
 
 class CustomView(StorableObject):
@@ -36,6 +40,8 @@ class CustomView(StorableObject):
     filters = None
     order_by = None
 
+    xml_root_node = 'custom_view'
+
     @property
     def user(self):
         return get_publisher().user_class.get(self.user_id)
@@ -57,7 +63,7 @@ class CustomView(StorableObject):
         self.formdef_type = value.xml_root_node
 
     def match(self, user, formdef):
-        if self.visibility == 'owner' and self.user_id != str(user.id):
+        if self.visibility == 'owner' and (user is None or self.user_id != str(user.id)):
             return False
         if self.formdef_type != formdef.xml_root_node:
             return False
@@ -134,3 +140,60 @@ class CustomView(StorableObject):
 
     def get_default_filters(self):
         return [key[7:] for key in self.filters if key.startswith('filter-')]
+
+    def export_to_xml(self, charset=None):
+        root = ET.Element(self.xml_root_node)
+        fields = [
+            'title',
+            'slug',
+            'visibility',
+            'filters',
+            'columns',
+            'order_by',
+        ]
+        for attribute in fields:
+            if getattr(self, attribute, None) is not None:
+                val = getattr(self, attribute)
+                el = ET.SubElement(root, attribute)
+                if attribute == 'columns':
+                    for field_dict in self.columns.get('list') or []:
+                        if not isinstance(field_dict, dict):
+                            continue
+                        for k, v in sorted(field_dict.items()):
+                            text_value = force_text(v, charset, errors='replace')
+                            ET.SubElement(el, k).text = text_value
+                elif type(val) is dict:
+                    for k, v in sorted(val.items()):
+                        text_value = force_text(v, charset, errors='replace')
+                        ET.SubElement(el, k).text = text_value
+                elif isinstance(val, str):
+                    el.text = force_text(val, charset, errors='replace')
+                else:
+                    el.text = str(val)
+        return root
+
+    def init_with_xml(self, elem, charset):
+        fields = [
+            'title',
+            'slug',
+            'visibility',
+            'filters',
+            'columns',
+            'order_by',
+        ]
+        for attribute in fields:
+            el = elem.find(attribute)
+            if el is None:
+                continue
+            if attribute == 'filters':
+                v = {}
+                for e in el:
+                    v[e.tag] = xml_node_text(e)
+                setattr(self, attribute, v)
+            elif attribute == 'columns':
+                v = []
+                for e in el:
+                    v.append({e.tag: xml_node_text(e)})
+                setattr(self, attribute, {'list': v})
+            else:
+                setattr(self, attribute, xml_node_text(el))
diff --git a/wcs/formdef.py b/wcs/formdef.py
index 9db0dcdb..e4dda958 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -389,8 +389,14 @@ class FormDef(StorableObject):
             from . import sql
             sql.do_formdef_tables(self, rebuild_views=True,
                     rebuild_global_views=True)
+        self.store_related_custom_views()
         return t
 
+    def store_related_custom_views(self):
+        for view in getattr(self, '_custom_views', []):
+            view.formdef = self
+            view.store()
+
     def get_all_fields(self):
         return (self.fields or []) + self.workflow.get_backoffice_fields()
 
@@ -1000,6 +1006,11 @@ class FormDef(StorableObject):
             else:
                 pass # TODO: extend support to other types
 
+        custom_views = ET.SubElement(root, 'custom_views')
+        for view in get_publisher().custom_view_class.select():
+            if view.match(user=None, formdef=self):
+                custom_views.append(view.export_to_xml(charset=charset))
+
         geolocations = ET.SubElement(root, 'geolocations')
         for geoloc_key, geoloc_label in (self.geolocations or {}).items():
             element = ET.SubElement(geolocations, 'geolocation')
@@ -1135,6 +1146,12 @@ class FormDef(StorableObject):
                 option_value.set_content(base64.decodebytes(force_bytes(option.find('content').text)))
             formdef.workflow_options[option.attrib.get('varname')] = option_value
 
+        formdef._custom_views = []
+        for view in tree.findall('custom_views/%s' % get_publisher().custom_view_class.xml_root_node):
+            view_o = get_publisher().custom_view_class()
+            view_o.init_with_xml(view, charset)
+            formdef._custom_views.append(view_o)
+
         if tree.find('last_modification') is not None:
             node = tree.find('last_modification')
             formdef.last_modification_time = time.strptime(node.text, '%Y-%m-%d %H:%M:%S')
@@ -1474,6 +1491,8 @@ class FormDef(StorableObject):
         if self.lightweight and 'fields' in odict:
             # will be stored independently
             del odict['fields']
+        if '_custom_views' in odict:
+            del odict['_custom_views']
         return odict
 
     def __setstate__(self, dict):
-- 
2.28.0

