From f5fe99d347d5852e4b65616160ffd062642e644d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= 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 6da39517f..b23277fb9 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 b10465e0b..848e2f63f 100644 --- a/tests/test_formdef_import.py +++ b/tests/test_formdef_import.py @@ -697,3 +697,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 ba62401d9..0d0d2aa2e 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -251,6 +251,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) @@ -259,6 +275,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): @@ -286,13 +303,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 7ae95e72a..b7f0b60d6 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 . +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): @@ -37,6 +41,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) @@ -62,7 +68,7 @@ class CustomView(StorableObject): return False if self.formdef_id != str(formdef.id): return False - 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 return True @@ -135,3 +141,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 1807e9e70..f4c3b4b3f 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') @@ -1153,6 +1164,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') @@ -1492,6 +1509,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):