From 41298fb30877f9a0cd17f97117a3ba77f7befaaa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Mon, 9 Mar 2020 09:35:09 +0100
Subject: [PATCH] backoffice: add support for custom views (#4507)

---
 tests/test_api.py                   |  46 +++
 tests/test_backoffice_pages.py      | 451 ++++++++++++++++++++--------
 tests/utilities.py                  |   5 +-
 wcs/api.py                          |  10 +
 wcs/backoffice/data_management.py   |  29 +-
 wcs/backoffice/management.py        | 255 +++++++++++++---
 wcs/custom_views.py                 | 127 ++++++++
 wcs/publisher.py                    |   4 +
 wcs/qommon/static/css/dc2/admin.css |  29 +-
 wcs/qommon/static/js/wcs.listing.js |  46 ++-
 wcs/sql.py                          | 124 +++++++-
 11 files changed, 931 insertions(+), 195 deletions(-)
 create mode 100644 wcs/custom_views.py

diff --git a/tests/test_api.py b/tests/test_api.py
index 18b6235dc..f193cfce4 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -2031,6 +2031,52 @@ def test_api_ods_formdata(pub, local_user):
     assert len(ods_sheet.findall('.//{%s}table-row' % ods.NS['table'])) == 311
 
 
+def test_api_list_formdata_custom_view(pub, local_user):
+    Role.wipe()
+    role = Role(name='test')
+    role.store()
+
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'test'
+    formdef.workflow_roles = {'_receiver': role.id}
+    formdef.fields = [fields.StringField(id='0', label='foobar', varname='foobar'),]
+    formdef.store()
+
+    data_class = formdef.data_class()
+    data_class.wipe()
+
+    for i in range(30):
+        formdata = data_class()
+        formdata.data = {'0': 'FOO BAR %d' % i}
+        formdata.user_id = local_user.id
+        formdata.just_created()
+        if i % 3 == 0:
+            formdata.jump_status('new')
+        else:
+            formdata.jump_status('finished')
+        formdata.store()
+
+    # add proper role to user
+    local_user.roles = [role.id]
+    local_user.store()
+
+    # check it now gets the data
+    resp = get_app(pub).get(sign_uri('/api/forms/test/list', user=local_user))
+    assert len(resp.json) == 30
+
+    custom_view = pub.custom_view_class()
+    custom_view.title = 'custom view'
+    custom_view.formdef = formdef
+    custom_view.columns = {'list': [{'id': '0'}]}
+    custom_view.filters = {"filter": "done", "filter-status": "on"}
+    custom_view.visibility = 'any'
+    custom_view.store()
+
+    resp = get_app(pub).get(sign_uri('/api/forms/test/list/custom-view', user=local_user))
+    assert len(resp.json['data']) == 20
+
+
 def test_api_global_geojson(pub, local_user):
     Role.wipe()
     role = Role(name='test')
diff --git a/tests/test_backoffice_pages.py b/tests/test_backoffice_pages.py
index 7c70a0e62..d7ae4bebc 100644
--- a/tests/test_backoffice_pages.py
+++ b/tests/test_backoffice_pages.py
@@ -122,6 +122,7 @@ def create_environment(pub, set_receiver=True):
     Workflow.wipe()
     Category.wipe()
     FormDef.wipe()
+    pub.custom_view_class.wipe()
     formdef = FormDef()
     formdef.name = 'form title'
     if set_receiver:
@@ -415,15 +416,15 @@ def test_backoffice_listing_pagination(pub):
 
     resp = resp.click(re.compile('^2$')) # second page
     assert resp.text.count('data-link') == 5
-    assert resp.form['offset'].value == '5'
+    assert resp.forms['listing-settings']['offset'].value == '5'
 
     resp = resp.click(re.compile('^3$')) # third page
     assert resp.text.count('data-link') == 5
-    assert resp.form['offset'].value == '10'
+    assert resp.forms['listing-settings']['offset'].value == '10'
 
     resp = resp.click(re.compile('^4$')) # fourth page
     assert resp.text.count('data-link') == 2
-    assert resp.form['offset'].value == '15'
+    assert resp.forms['listing-settings']['offset'].value == '15'
 
     with pytest.raises(IndexError): # no fifth page
         resp = resp.click(re.compile('^5$'))
@@ -437,7 +438,7 @@ def test_backoffice_listing_pagination(pub):
     # try an overbound offset
     resp = app.get('/backoffice/management/form-title/?limit=5&offset=30')
     resp = resp.follow()
-    assert resp.form['offset'].value == '0'
+    assert resp.forms['listing-settings']['offset'].value == '0'
 
 
 def test_backoffice_listing_order(pub):
@@ -541,6 +542,12 @@ def test_backoffice_columns(pub):
     assert resp.text.count('data-link') == 17 # 17 rows
     assert resp.text.count('FOO BAR') == 0 # no field 1 column
 
+    # change column order
+    assert resp.forms['listing-settings']['columns-order'].value == 'id,time,last_update_time,user-label,2,status,1,3,anonymised'
+    resp.forms['listing-settings']['columns-order'].value = 'user-label,id,time,last_update_time,2,status,1,3,anonymised'
+    resp = resp.forms['listing-settings'].submit()
+    assert resp.text.find('<span>User Label</span>') < resp.text.find('<span>Number</span>')
+
 
 def test_backoffice_channel_column(pub):
     if not pub.site_options.has_section('variables'):
@@ -571,15 +578,15 @@ def test_backoffice_submission_agent_column(pub):
 
     app = login(get_app(pub))
     resp = app.get('/backoffice/management/form-title/')
-    assert not 'submission_agent' in resp.form.fields
+    assert not 'submission_agent' in resp.forms['listing-settings'].fields
 
     formdef = FormDef.get_by_urlname('form-title')
     formdef.backoffice_submission_roles = user.roles
     formdef.store()
     resp = app.get('/backoffice/management/form-title/')
     assert resp.text.count('</th>') == 8 # six columns
-    resp.form['submission_agent'].checked = True
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['submission_agent'].checked = True
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('</th>') == 9 # seven columns
     assert resp.text.count('data-link') == 17 # 17 rows
     assert not '>agent<' in resp.text
@@ -592,13 +599,13 @@ def test_backoffice_submission_agent_column(pub):
         formdata.store()
 
     resp = app.get('/backoffice/management/form-title/')
-    resp.form['submission_agent'].checked = True
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['submission_agent'].checked = True
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('>agent<') == 17
 
     resp = resp.click('Export as CSV File')
     assert len(resp.text.splitlines()) == 18 # 17 + header line
-    assert resp.text.count(',agent,') == 17
+    assert resp.text.count(',agent') == 17
 
 
 def test_backoffice_image_column(pub):
@@ -669,25 +676,25 @@ def test_backoffice_default_filter(pub):
     create_environment(pub)
     app = login(get_app(pub))
     resp = app.get('/backoffice/management/form-title/')
-    assert not 'filter-2-value' in resp.form.fields
+    assert not 'filter-2-value' in resp.forms['listing-settings'].fields
 
     formdef = FormDef.get_by_urlname('form-title')
     formdef.fields[1].in_filters = True
     formdef.store()
     resp = app.get('/backoffice/management/form-title/')
-    assert 'filter-2-value' in resp.form.fields
+    assert 'filter-2-value' in resp.forms['listing-settings'].fields
 
     # same check for items field
     formdef.fields.append(
             fields.ItemsField(id='4', label='4th field', type='items', items=['foo', 'bar', 'baz']))
     formdef.store()
     resp = app.get('/backoffice/management/form-title/')
-    assert not 'filter-4-value' in resp.form.fields
+    assert not 'filter-4-value' in resp.forms['listing-settings'].fields
 
     formdef.fields[-1].in_filters = True
     formdef.store()
     resp = app.get('/backoffice/management/form-title/')
-    assert 'filter-4-value' in resp.form.fields
+    assert 'filter-4-value' in resp.forms['listing-settings'].fields
 
 
 def test_backoffice_bool_filter(pub):
@@ -705,18 +712,18 @@ def test_backoffice_bool_filter(pub):
 
     app = login(get_app(pub))
     resp = app.get('/backoffice/management/form-title/')
-    resp.form['filter-4'].checked = True
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-4'].checked = True
+    resp = resp.forms['listing-settings'].submit()
 
-    assert resp.form['filter-4-value'].value == ''
+    assert resp.forms['listing-settings']['filter-4-value'].value == ''
 
-    resp.form['filter-4-value'].value = 'true'
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-4-value'].value = 'true'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('<td>Yes</td>') > 0
     assert resp.text.count('<td>No</td>') == 0
 
-    resp.form['filter-4-value'].value = 'false'
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-4-value'].value = 'false'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count('<td>Yes</td>') == 0
     assert resp.text.count('<td>No</td>') > 0
 
@@ -747,27 +754,27 @@ def test_backoffice_item_filter(pub):
 
     app = login(get_app(pub))
     resp = app.get('/backoffice/management/form-title/')
-    resp.form['filter-4'].checked = True
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-4'].checked = True
+    resp = resp.forms['listing-settings'].submit()
 
-    assert resp.form['filter-4-value'].value == ''
+    assert resp.forms['listing-settings']['filter-4-value'].value == ''
 
-    resp.form['filter-4-value'].value = 'â'
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-4-value'].value = 'â'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count(u'<td>â</td>') > 0
     assert resp.text.count(u'<td>b</td>') == 0
     assert resp.text.count(u'<td>d</td>') == 0
 
-    resp.form['filter-4-value'].value = 'b'
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-4-value'].value = 'b'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count(u'<td>â</td>') == 0
     assert resp.text.count(u'<td>b</td>') > 0
     assert resp.text.count(u'<td>d</td>') == 0
 
     if not pub.is_using_postgresql():
         # in pickle all options are always displayed
-        resp.form['filter-4-value'].value = 'c'
-        resp = resp.form.submit()
+        resp.forms['listing-settings']['filter-4-value'].value = 'c'
+        resp = resp.forms['listing-settings'].submit()
         assert resp.text.count(u'<td>â</td>') == 0
         assert resp.text.count(u'<td>b</td>') == 0
         assert resp.text.count(u'<td>c</td>') == 0
@@ -775,7 +782,7 @@ def test_backoffice_item_filter(pub):
     else:
         # in postgresql, option 'c' is never used so not even listed
         with pytest.raises(ValueError):
-            resp.form['filter-4-value'].value = 'c'
+            resp.forms['listing-settings']['filter-4-value'].value = 'c'
 
         # check json view used to fill select filters from javascript
         resp2 = app.get(resp.request.path + 'filter-options?filter_field_id=4&' + resp.request.query_string)
@@ -785,8 +792,8 @@ def test_backoffice_item_filter(pub):
         resp2 = app.get(resp.request.path + 'filter-options?filter_field_id=7&' + resp.request.query_string, status=404)
 
         for status in ('all', 'waiting', 'pending', 'done', 'accepted'):
-            resp.form['filter'] = status
-            resp = resp.form.submit()
+            resp.forms['listing-settings']['filter'] = status
+            resp = resp.forms['listing-settings'].submit()
             resp2 = app.get(resp.request.path + 'filter-options?filter_field_id=4&' + resp.request.query_string)
             if status == 'accepted':
                 assert [x['id'] for x in resp2.json['data']] == []
@@ -834,34 +841,34 @@ def test_backoffice_item_double_filter(pub):
 
     app = login(get_app(pub))
     resp = app.get('/backoffice/management/form-title/')
-    resp.form['filter-4'].checked = True
-    resp.form['filter-5'].checked = True
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-4'].checked = True
+    resp.forms['listing-settings']['filter-5'].checked = True
+    resp = resp.forms['listing-settings'].submit()
 
-    assert resp.form['filter-4-value'].value == ''
-    assert resp.form['filter-5-value'].value == ''
-    assert [x[0] for x in resp.form['filter-4-value'].options] == ['', 'a', 'b']
-    assert [x[0] for x in resp.form['filter-5-value'].options] == ['', 'A', 'B', 'C']
+    assert resp.forms['listing-settings']['filter-4-value'].value == ''
+    assert resp.forms['listing-settings']['filter-5-value'].value == ''
+    assert [x[0] for x in resp.forms['listing-settings']['filter-4-value'].options] == ['', 'a', 'b']
+    assert [x[0] for x in resp.forms['listing-settings']['filter-5-value'].options] == ['', 'A', 'B', 'C']
 
-    resp.form['filter-4-value'].value = 'a'
-    resp = resp.form.submit()
-    assert [x[0] for x in resp.form['filter-4-value'].options] == ['', 'a', 'b']
-    assert [x[0] for x in resp.form['filter-5-value'].options] == ['', 'A', 'B', 'C']
+    resp.forms['listing-settings']['filter-4-value'].value = 'a'
+    resp = resp.forms['listing-settings'].submit()
+    assert [x[0] for x in resp.forms['listing-settings']['filter-4-value'].options] == ['', 'a', 'b']
+    assert [x[0] for x in resp.forms['listing-settings']['filter-5-value'].options] == ['', 'A', 'B', 'C']
 
-    resp.form['filter-4-value'].value = 'b'
-    resp = resp.form.submit()
-    assert [x[0] for x in resp.form['filter-4-value'].options] == ['', 'a', 'b']
-    assert [x[0] for x in resp.form['filter-5-value'].options] == ['', 'B']
+    resp.forms['listing-settings']['filter-4-value'].value = 'b'
+    resp = resp.forms['listing-settings'].submit()
+    assert [x[0] for x in resp.forms['listing-settings']['filter-4-value'].options] == ['', 'a', 'b']
+    assert [x[0] for x in resp.forms['listing-settings']['filter-5-value'].options] == ['', 'B']
 
-    resp.form['filter-5-value'].value = 'B'
-    resp = resp.form.submit()
-    assert [x[0] for x in resp.form['filter-4-value'].options] == ['', 'a', 'b']
-    assert [x[0] for x in resp.form['filter-5-value'].options] == ['', 'B']
+    resp.forms['listing-settings']['filter-5-value'].value = 'B'
+    resp = resp.forms['listing-settings'].submit()
+    assert [x[0] for x in resp.forms['listing-settings']['filter-4-value'].options] == ['', 'a', 'b']
+    assert [x[0] for x in resp.forms['listing-settings']['filter-5-value'].options] == ['', 'B']
 
-    resp.form['filter-4-value'].value = ''
-    resp = resp.form.submit()
-    assert [x[0] for x in resp.form['filter-4-value'].options] == ['', 'a', 'b']
-    assert [x[0] for x in resp.form['filter-5-value'].options] == ['', 'A', 'B', 'C']
+    resp.forms['listing-settings']['filter-4-value'].value = ''
+    resp = resp.forms['listing-settings'].submit()
+    assert [x[0] for x in resp.forms['listing-settings']['filter-4-value'].options] == ['', 'a', 'b']
+    assert [x[0] for x in resp.forms['listing-settings']['filter-5-value'].options] == ['', 'A', 'B', 'C']
 
 
 def test_backoffice_bofield_item_filter(pub):
@@ -894,27 +901,27 @@ def test_backoffice_bofield_item_filter(pub):
 
     app = login(get_app(pub))
     resp = app.get('/backoffice/management/form-title/')
-    resp.form['filter-bo0-1'].checked = True
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-bo0-1'].checked = True
+    resp = resp.forms['listing-settings'].submit()
 
-    assert resp.form['filter-bo0-1-value'].value == ''
+    assert resp.forms['listing-settings']['filter-bo0-1-value'].value == ''
 
-    resp.form['filter-bo0-1-value'].value = 'â'
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-bo0-1-value'].value = 'â'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count(u'<td>â</td>') > 0
     assert resp.text.count(u'<td>b</td>') == 0
     assert resp.text.count(u'<td>d</td>') == 0
 
-    resp.form['filter-bo0-1-value'].value = 'b'
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-bo0-1-value'].value = 'b'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count(u'<td>â</td>') == 0
     assert resp.text.count(u'<td>b</td>') > 0
     assert resp.text.count(u'<td>d</td>') == 0
 
     if not pub.is_using_postgresql():
         # in pickle all options are always displayed
-        resp.form['filter-bo0-1-value'].value = 'c'
-        resp = resp.form.submit()
+        resp.forms['listing-settings']['filter-bo0-1-value'].value = 'c'
+        resp = resp.forms['listing-settings'].submit()
         assert resp.text.count(u'<td>â</td>') == 0
         assert resp.text.count(u'<td>b</td>') == 0
         assert resp.text.count(u'<td>c</td>') == 0
@@ -922,7 +929,7 @@ def test_backoffice_bofield_item_filter(pub):
     else:
         # in postgresql, option 'c' is never used so not even listed
         with pytest.raises(ValueError):
-            resp.form['filter-bo0-1-value'].value = 'c'
+            resp.forms['listing-settings']['filter-bo0-1-value'].value = 'c'
 
         # check json view used to fill select filters from javascript
         resp2 = app.get(resp.request.path + 'filter-options?filter_field_id=bo0-1&' + resp.request.query_string)
@@ -931,8 +938,8 @@ def test_backoffice_bofield_item_filter(pub):
         assert [x['id'] for x in resp2.json['data']] == ['d']
 
         for status in ('all', 'waiting', 'pending', 'done', 'accepted'):
-            resp.form['filter'] = status
-            resp = resp.form.submit()
+            resp.forms['listing-settings']['filter'] = status
+            resp = resp.forms['listing-settings'].submit()
             resp2 = app.get(resp.request.path + 'filter-options?filter_field_id=bo0-1&' + resp.request.query_string)
             if status == 'accepted':
                 assert [x['id'] for x in resp2.json['data']] == []
@@ -966,19 +973,19 @@ def test_backoffice_items_filter(pub):
 
     app = login(get_app(pub))
     resp = app.get('/backoffice/management/form-title/')
-    resp.form['filter-4'].checked = True
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-4'].checked = True
+    resp = resp.forms['listing-settings'].submit()
 
-    assert resp.form['filter-4-value'].value == ''
+    assert resp.forms['listing-settings']['filter-4-value'].value == ''
 
-    resp.form['filter-4-value'].value = 'â'
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-4-value'].value = 'â'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count(u'<td>â, b</td>') > 0
     assert resp.text.count(u'<td>â</td>') > 0
     assert resp.text.count(u'<td>b, d</td>') == 0
 
-    resp.form['filter-4-value'].value = 'b'
-    resp = resp.form.submit()
+    resp.forms['listing-settings']['filter-4-value'].value = 'b'
+    resp = resp.forms['listing-settings'].submit()
     assert resp.text.count(u'<td>â, b</td>') > 0
     assert resp.text.count(u'<td>â</td>') == 0
     assert resp.text.count(u'<td>b, d</td>') > 0
@@ -986,10 +993,10 @@ def test_backoffice_items_filter(pub):
     if pub.is_using_postgresql():
         # option 'c' is never used so not even listed
         with pytest.raises(ValueError):
-            resp.form['filter-4-value'].value = 'c'
+            resp.forms['listing-settings']['filter-4-value'].value = 'c'
     else:
-        resp.form['filter-4-value'].value = 'c'
-        resp = resp.form.submit()
+        resp.forms['listing-settings']['filter-4-value'].value = 'c'
+        resp = resp.forms['listing-settings'].submit()
         assert resp.text.count(u'<td>â, b</td>') == 0
         assert resp.text.count(u'<td>â</td>') == 0
         assert resp.text.count(u'<td>b, d</td>') == 0
@@ -1020,39 +1027,39 @@ def test_backoffice_csv(pub):
     assert resp.text.splitlines()[1].split(',')[7] == 'aa'
 
     resp = app.get('/backoffice/management/form-title/')
-    resp.forms[0]['filter'] = 'all'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter'] = 'all'
+    resp = resp.forms['listing-settings'].submit()
     resp_csv = resp.click('Export as CSV File')
     assert len(resp_csv.text.splitlines()) == 51
 
     # test status filter
-    resp.forms[0]['filter'] = 'pending'
-    resp.forms[0]['filter-2'].checked = True
-    resp = resp.forms[0].submit()
-    resp.forms[0]['filter-2-value'] = 'baz'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter'] = 'pending'
+    resp.forms['listing-settings']['filter-2'].checked = True
+    resp = resp.forms['listing-settings'].submit()
+    resp.forms['listing-settings']['filter-2-value'] = 'baz'
+    resp = resp.forms['listing-settings'].submit()
     resp_csv = resp.click('Export as CSV File')
     assert len(resp_csv.text.splitlines()) == 9
 
     # test criteria filters
-    resp.forms[0]['filter-start'].checked = True
-    resp = resp.forms[0].submit()
-    resp.forms[0]['filter-start-value'] = datetime.datetime(2015, 2, 1).strftime('%Y-%m-%d')
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-start'].checked = True
+    resp = resp.forms['listing-settings'].submit()
+    resp.forms['listing-settings']['filter-start-value'] = datetime.datetime(2015, 2, 1).strftime('%Y-%m-%d')
+    resp = resp.forms['listing-settings'].submit()
     resp_csv = resp.click('Export as CSV File')
     assert len(resp_csv.text.splitlines()) == 1
 
-    resp.forms[0]['filter-start-value'] = datetime.datetime(2014, 2, 1).strftime('%Y-%m-%d')
-    resp = resp.forms[0].submit()
-    resp.forms[0]['filter-2-value'] = 'baz'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-start-value'] = datetime.datetime(2014, 2, 1).strftime('%Y-%m-%d')
+    resp = resp.forms['listing-settings'].submit()
+    resp.forms['listing-settings']['filter-2-value'] = 'baz'
+    resp = resp.forms['listing-settings'].submit()
     resp_csv = resp.click('Export as CSV File')
     assert len(resp_csv.text.splitlines()) == 9
     assert 'Created' in resp_csv.text.splitlines()[0]
 
     # test column selection
-    resp.form['time'].checked = False
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['time'].checked = False
+    resp = resp.forms['listing-settings'].submit()
     resp_csv = resp.click('Export as CSV File')
     assert 'Created' not in resp_csv.text.splitlines()[0]
 
@@ -1117,11 +1124,11 @@ def test_backoffice_csv_export_channel(pub):
     assert 'Channel' not in resp_csv.text.splitlines()[0]
 
     # add submission channel column
-    resp.form['submission_channel'].checked = True
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['submission_channel'].checked = True
+    resp = resp.forms['listing-settings'].submit()
     resp_csv = resp.click('Export as CSV File')
-    assert resp_csv.text.splitlines()[0].split(',')[1] == 'Channel'
-    assert resp_csv.text.splitlines()[1].split(',')[1] == 'Web'
+    assert resp_csv.text.splitlines()[0].split(',')[-1] == 'Channel'
+    assert resp_csv.text.splitlines()[1].split(',')[-1] == 'Web'
 
 
 def test_backoffice_csv_export_anonymised(pub):
@@ -1137,8 +1144,8 @@ def test_backoffice_csv_export_anonymised(pub):
     assert resp_csv.text.splitlines()[0].split(',')[-1] != 'Anonymised'
 
     # add anonymised column
-    resp.form['anonymised'].checked = True
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['anonymised'].checked = True
+    resp = resp.forms['listing-settings'].submit()
     resp_csv = resp.click('Export as CSV File')
     assert resp_csv.text.splitlines()[0].split(',')[-1] == 'Anonymised'
     assert resp_csv.text.splitlines()[1].split(',')[-1] == 'No'
@@ -1265,8 +1272,8 @@ def test_backoffice_statistics(pub):
     assert 'To Status &quot;Finished&quot;' in resp.text
     assert not '<h2>Filters</h2>' in resp.text
 
-    resp.forms[0]['filter-end-value'] = '2013-01-01'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-end-value'] = '2013-01-01'
+    resp = resp.forms['listing-settings'].submit()
     assert 'Total number of records: 0' in resp.text
     assert '<h2>Filters</h2>' in resp.text
     assert 'End: 2013-01-01' in resp.text
@@ -1388,36 +1395,36 @@ def test_backoffice_statistics_status_filter(pub):
     app = login(get_app(pub))
     resp = app.get('/backoffice/management/form-title/')
     resp = resp.click('Statistics')
-    assert 'filter' not in resp.forms[0].fields # status is not displayed by default
+    assert 'filter' not in resp.forms['listing-settings'].fields # status is not displayed by default
     assert not '<h2>Filters</h2>' in resp.text
 
     # add 'status' as a filter
-    resp.forms[0]['filter-status'].checked = True
-    resp = resp.forms[0].submit()
-    assert 'filter' in resp.forms[0].fields
+    resp.forms['listing-settings']['filter-status'].checked = True
+    resp = resp.forms['listing-settings'].submit()
+    assert 'filter' in resp.forms['listing-settings'].fields
     assert not '<h2>Filters</h2>' in resp.text
 
-    assert resp.forms[0]['filter'].value == 'all'
-    resp.forms[0]['filter'].value = 'pending'
-    resp = resp.forms[0].submit()
+    assert resp.forms['listing-settings']['filter'].value == 'all'
+    resp.forms['listing-settings']['filter'].value = 'pending'
+    resp = resp.forms['listing-settings'].submit()
     assert 'Total number of records: 17' in resp.text
     assert '<h2>Filters</h2>' in resp.text
     assert 'Status: Pending' in resp.text
 
-    resp.forms[0]['filter'].value = 'done'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter'].value = 'done'
+    resp = resp.forms['listing-settings'].submit()
     assert 'Total number of records: 33' in resp.text
     assert '<h2>Filters</h2>' in resp.text
     assert 'Status: Done' in resp.text
 
-    resp.forms[0]['filter'].value = 'rejected'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter'].value = 'rejected'
+    resp = resp.forms['listing-settings'].submit()
     assert 'Total number of records: 0' in resp.text
     assert '<h2>Filters</h2>' in resp.text
     assert 'Status: Rejected' in resp.text
 
-    resp.forms[0]['filter'].value = 'all'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter'].value = 'all'
+    resp = resp.forms['listing-settings'].submit()
     assert 'Total number of records: 50' in resp.text
 
 
@@ -1429,34 +1436,34 @@ def test_backoffice_statistics_status_select(pub):
     resp = resp.click('Statistics')
     assert not 'filter-2-value' in resp.form.fields
 
-    resp.forms[0]['filter-2'].checked = True
-    resp = resp.forms[0].submit()
-    resp.forms[0]['filter-2-value'].value = 'bar'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-2'].checked = True
+    resp = resp.forms['listing-settings'].submit()
+    resp.forms['listing-settings']['filter-2-value'].value = 'bar'
+    resp = resp.forms['listing-settings'].submit()
     assert 'Total number of records: 13' in resp.text
 
-    resp.forms[0]['filter-2-value'].value = 'baz'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-2-value'].value = 'baz'
+    resp = resp.forms['listing-settings'].submit()
     assert 'Total number of records: 24' in resp.text
 
-    resp.forms[0]['filter-2-value'].value = 'foo'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-2-value'].value = 'foo'
+    resp = resp.forms['listing-settings'].submit()
     assert 'Total number of records: 13' in resp.text
     assert '<h2>Filters</h2>' in resp.text
     assert '2nd field: foo' in resp.text
 
     # check it's also possible to get back to the complete list
-    resp.forms[0]['filter-2-value'].value = ''
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-2-value'].value = ''
+    resp = resp.forms['listing-settings'].submit()
     assert 'Total number of records: 50' in resp.text
 
     # check it also works with item fields with a data source
     resp = app.get('/backoffice/management/form-title/')
     resp = resp.click('Statistics')
-    resp.forms[0]['filter-3'].checked = True
-    resp = resp.forms[0].submit()
-    resp.forms[0]['filter-3-value'].value = 'A'
-    resp = resp.forms[0].submit()
+    resp.forms['listing-settings']['filter-3'].checked = True
+    resp = resp.forms['listing-settings'].submit()
+    resp.forms['listing-settings']['filter-3-value'].value = 'A'
+    resp = resp.forms['listing-settings'].submit()
     assert 'Total number of records: 13' in resp.text
     assert '<h2>Filters</h2>' in resp.text
     assert '3rd field: aa' in resp.text
@@ -6185,3 +6192,187 @@ def test_backoffice_after_submit_location(pub):
     resp.form['comment'] = 'plop'
     resp = resp.form.submit('submit')
     assert resp.location == 'http://example.net/backoffice/management/form-title/%s/#' % formdata.id
+
+
+def test_backoffice_custom_view(pub):
+    create_superuser(pub)
+    create_environment(pub)
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/management/form-title/')
+    assert resp.text.count('<span>User Label</span>') == 1
+    assert resp.text.count('<tr') == 18
+
+    # columns
+    resp.forms['listing-settings']['user-label'].checked = False
+    resp = resp.forms['listing-settings'].submit()
+    # filters
+    resp.forms[0]['filter-2'].checked = True
+    resp = resp.forms['listing-settings'].submit()
+
+    resp.forms['listing-settings']['filter-2-value'] = 'baz'
+    resp = resp.forms['listing-settings'].submit()
+
+    assert resp.text.count('<span>User Label</span>') == 0
+    assert resp.text.count('<tr') == 9
+
+    resp.forms['save-custom-view']['title'] = 'custom test view'
+    resp = resp.forms['save-custom-view'].submit()
+    assert resp.location.endswith('/custom-test-view/')
+    resp = resp.follow()
+    assert resp.text.count('<span>User Label</span>') == 0
+    assert resp.text.count('<tr') == 9
+
+    resp.forms['listing-settings']['filter-2-value'] = 'foo'
+    resp = resp.forms['listing-settings'].submit()
+    assert resp.text.count('<tr') == 6
+    assert resp.forms['save-custom-view']['update'].checked is True
+    resp = resp.forms['save-custom-view'].submit()
+    assert resp.location.endswith('/custom-test-view/')
+    resp = resp.follow()
+    assert resp.text.count('<tr') == 6
+
+    resp = app.get('/backoffice/management/other-form/')
+    assert 'custom test view' not in resp
+
+    # check it's not possible to create a view without any columns
+    for field_key in resp.forms['listing-settings'].fields:
+        if not field_key:
+            continue
+        if field_key.startswith('filter'):
+            continue
+        if resp.forms['listing-settings'][field_key].attrs.get('type') != 'checkbox':
+            continue
+        resp.forms['listing-settings'][field_key].checked = False
+    resp = resp.forms['listing-settings'].submit()
+    resp.forms['save-custom-view']['title'] = 'custom test view'
+    resp = resp.forms['save-custom-view'].submit().follow()
+    assert 'Views must have at least one column.' in resp.text
+
+
+def test_backoffice_custom_view_delete(pub):
+    create_superuser(pub)
+    create_environment(pub)
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/management/form-title/')
+
+    # columns
+    resp.forms['listing-settings']['user-label'].checked = False
+    resp = resp.forms['listing-settings'].submit()
+    resp.forms['save-custom-view']['title'] = 'custom test view'
+    resp = resp.forms['save-custom-view'].submit()
+    assert resp.location.endswith('/custom-test-view/')
+    resp = resp.follow()
+    resp = resp.click('Delete View')
+    resp = resp.form.submit()
+    assert resp.location.endswith('/management/form-title/')
+    resp = resp.follow()
+    assert 'custom test view' not in resp.text
+
+
+def test_backoffice_custom_map_view(pub):
+    test_backoffice_custom_view(pub)
+
+    formdef = FormDef.get_by_urlname('form-title')
+    formdef.geolocations = {'base': 'Geolocafoobar'}
+    formdef.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/management/form-title/')
+    resp = resp.click('custom test view')
+    assert resp.text.count('<span>User Label</span>') == 0
+    assert resp.text.count('<tr') == 6
+    resp = resp.click('Plot on a Map')
+    assert resp.forms['listing-settings']['filter-2-value'].value == 'foo'
+
+
+def test_backoffice_custom_view_visibility(pub):
+    create_environment(pub)
+    create_superuser(pub)
+
+    formdef = FormDef.get_by_urlname('form-title')
+    agent = pub.user_class(name='agent')
+    agent.roles = [formdef.workflow_roles['_receiver']]
+    agent.store()
+
+    account = PasswordAccount(id='agent')
+    account.set_password('agent')
+    account.user_id = agent.id
+    account.store()
+
+    app = login(get_app(pub), username='agent', password='agent')
+    resp = app.get('/backoffice/management/form-title/')
+
+    # columns
+    resp.forms['listing-settings']['user-label'].checked = False
+    resp = resp.forms['listing-settings'].submit()
+
+    assert resp.text.count('<span>User Label</span>') == 0
+
+    resp.forms['save-custom-view']['title'] = 'custom test view'
+    assert 'visibility' not in resp.forms['save-custom-view'].fields
+    resp = resp.forms['save-custom-view'].submit()
+    assert resp.location.endswith('/custom-test-view/')
+    resp = resp.follow()
+    assert resp.text.count('<span>User Label</span>') == 0
+
+    # second agent
+    agent2 = pub.user_class(name='agent2')
+    agent2.roles = [formdef.workflow_roles['_receiver']]
+    agent2.store()
+
+    account = PasswordAccount(id='agent2')
+    account.set_password('agent2')
+    account.user_id = agent2.id
+    account.store()
+
+    app = login(get_app(pub), username='agent2', password='agent2')
+    resp = app.get('/backoffice/management/form-title/')
+    assert 'custom test view' not in resp
+
+    # shared custom view
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/management/form-title/')
+    resp = resp.forms['listing-settings'].submit()
+    resp.forms['save-custom-view']['title'] = 'shared view'
+    resp.forms['save-custom-view']['visibility'] = 'any'
+    resp = resp.forms['save-custom-view'].submit()
+
+    app = login(get_app(pub), username='agent2', password='agent2')
+    resp = app.get('/backoffice/management/form-title/')
+    resp = resp.click('shared view')
+
+
+def test_carddata_custom_view(pub, studio):
+    CardDef.wipe()
+    user = create_user(pub)
+    app = login(get_app(pub))
+    carddef = CardDef()
+    carddef.name = 'foo'
+    carddef.fields = [
+        fields.StringField(id='1', label='Test', type='string', varname='foo'),
+    ]
+    carddef.backoffice_submission_roles = user.roles
+    carddef.workflow_roles = {'_editor': user.roles[0]}
+    carddef.store()
+    carddef.data_class().wipe()
+
+    for i in range(50):
+        carddata = carddef.data_class()()
+        carddata.data = {'1': 'FOO %s' % i}
+        carddata.just_created()
+        carddata.store()
+
+    resp = app.get('/backoffice/data/foo/')
+    if pub.is_using_postgresql():
+        assert resp.text.count('<tr') == 21  # header + rows of data
+    else:
+        # no pagination
+        assert resp.text.count('<tr') == 51  # header + rows of data
+
+    resp = resp.forms['listing-settings'].submit()
+    resp.forms['save-custom-view']['title'] = 'card view'
+    resp = resp.forms['save-custom-view'].submit()
+    assert resp.location.endswith('/card-view/')
+    resp = resp.follow()
diff --git a/tests/utilities.py b/tests/utilities.py
index 87a3ee53e..d04c861bc 100644
--- a/tests/utilities.py
+++ b/tests/utilities.py
@@ -9,7 +9,7 @@ import shutil
 import sys
 import threading
 
-from wcs import sql, sessions
+from wcs import sql, sessions, custom_views
 
 from webtest import TestApp
 from quixote import cleanup, get_publisher
@@ -79,11 +79,13 @@ def create_temporary_pub(sql_mode=False, templates_mode=False, lazy_mode=False):
         pub.user_class = sql.SqlUser
         pub.tracking_code_class = sql.TrackingCode
         pub.session_class = sql.Session
+        pub.custom_view_class = sql.CustomView
         pub.is_using_postgresql = lambda: True
     else:
         pub.user_class = User
         pub.tracking_code_class = TrackingCode
         pub.session_class = sessions.BasicSession
+        pub.custom_view_class = custom_views.CustomView
         pub.is_using_postgresql = lambda: False
 
     pub.session_manager_class = sessions.StorageSessionManager
@@ -165,6 +167,7 @@ def create_temporary_pub(sql_mode=False, templates_mode=False, lazy_mode=False):
         sql.do_user_table()
         sql.do_tracking_code_table()
         sql.do_session_table()
+        sql.do_custom_views_table()
         sql.do_meta_table()
 
         conn.close()
diff --git a/wcs/api.py b/wcs/api.py
index a577a57cb..2624a56f9 100644
--- a/wcs/api.py
+++ b/wcs/api.py
@@ -31,6 +31,7 @@ from .qommon import misc
 from .qommon.errors import (AccessForbiddenError, QueryError, TraversalError,
     UnknownNameIdAccessForbiddenError, RequestError)
 from .qommon.form import ComputedExpressionWidget, ConditionWidget
+from .qommon.storage import Equal
 
 from wcs.categories import Category
 from wcs.conditions import Condition, ValidationError
@@ -202,6 +203,15 @@ class ApiFormPage(BackofficeFormPage):
         return ApiFormdataPage(self.formdef, formdata)
 
     def _q_traverse(self, path):
+        if len(path) == 2 and path[0] == 'list':
+            if path[1] == '':
+                path = ['list']  # default view, with trailing slash
+            else:
+                # custom view
+                for view in self.get_custom_views([Equal('slug', path[1])]):
+                    self.view = view
+                    path = ['list']
+
         self.is_webhook = False
         if len(path) > 1:
             # webhooks have their own access checks, request cannot be blocked
diff --git a/wcs/backoffice/data_management.py b/wcs/backoffice/data_management.py
index ccc9c8ddc..218bb4f7e 100644
--- a/wcs/backoffice/data_management.py
+++ b/wcs/backoffice/data_management.py
@@ -80,15 +80,19 @@ class DataManagementDirectory(ManagementDirectory):
 
 class CardPage(FormPage):
     _q_exports = ['', 'csv', 'xls', 'ods', 'json', 'export', 'map', 'geojson', 'add',
+                  ('save-view', 'save_view'), ('delete-view', 'delete_view'),
                   ('import-csv', 'import_csv'),
                   ('data-sample-csv', 'data_sample_csv')]
+    admin_permisison = 'cards'
 
-    def __init__(self, component):
+    def __init__(self, component=None, formdef=None, view=None):
         try:
-            self.formdef = CardDef.get_by_urlname(component)
+            self.formdef = formdef if formdef else CardDef.get_by_urlname(component)
         except KeyError:
             raise errors.TraversalError()
-        self.add = CardFillPage(component)
+        self.add = CardFillPage(self.formdef.url_name)
+        if view:
+            self.view = view
 
     def can_user_add_cards(self):
         if not self.formdef.backoffice_submission_roles:
@@ -104,13 +108,18 @@ class CardPage(FormPage):
         return htmltext('<span class="actions"><a href="./add/">%s</a></span>') % _('Add')
 
     def get_default_filters(self, mode):
+        if self.view:
+            return self.view.get_default_filters()
         return ()
 
     def get_default_columns(self):
-        field_ids = ['id', 'time']
-        for field in self.formdef.get_all_fields():
-            if hasattr(field, 'get_view_value') and field.include_in_listing:
-                field_ids.append(field.id)
+        if self.view:
+            field_ids = self.view.get_columns()
+        else:
+            field_ids = ['id', 'time']
+            for field in self.formdef.get_all_fields():
+                if hasattr(field, 'get_view_value') and field.include_in_listing:
+                    field_ids.append(field.id)
         return field_ids
 
     def get_filter_from_query(self, default=Ellipsis):
@@ -270,6 +279,12 @@ class CardPage(FormPage):
         return redirect('import-csv?job=%s' % job.id)
 
     def _q_lookup(self, component):
+
+        if not self.view:
+            for view in self.get_custom_views():
+                if view.slug == component:
+                    return self.__class__(formdef=self.formdef, view=view)
+
         try:
             filled = self.formdef.data_class().get(component)
         except KeyError:
diff --git a/wcs/backoffice/management.py b/wcs/backoffice/management.py
index a07283cb3..17c844e1a 100644
--- a/wcs/backoffice/management.py
+++ b/wcs/backoffice/management.py
@@ -1010,14 +1010,23 @@ class ManagementDirectory(Directory):
 
 class FormPage(Directory):
     _q_exports = ['', 'csv', 'stats', 'xls', 'ods', 'json', 'export', 'map',
-                  'geojson', ('filter-options', 'filter_options')]
-
-    def __init__(self, component):
-        try:
-            self.formdef = FormDef.get_by_urlname(component)
-        except KeyError:
-            raise errors.TraversalError()
-        get_response().breadcrumb.append( (component + '/', self.formdef.name) )
+                  'geojson', ('filter-options', 'filter_options'),
+                  ('save-view', 'save_view'), ('delete-view', 'delete_view'),]
+    view = None
+    admin_permisison = 'forms'
+
+    def __init__(self, component=None, formdef=None, view=None):
+        self.view_type = None
+        if component:
+            try:
+                self.formdef = FormDef.get_by_urlname(component)
+            except KeyError:
+                raise errors.TraversalError()
+            get_response().breadcrumb.append((component + '/', self.formdef.name))
+        else:
+            self.formdef = formdef
+            self.view = view
+            get_response().breadcrumb.append((view.slug + '/', view.title))
 
     def check_access(self, api_name=None):
         session = get_session()
@@ -1033,6 +1042,11 @@ class FormPage(Directory):
             else:
                 raise errors.AccessUnauthorizedError()
 
+    def get_custom_views(self, criterias=None):
+        for view in get_publisher().custom_view_class.select(clause=criterias):
+            if view.match(get_request().user, self.formdef):
+                yield view
+
     def get_formdata_sidebar_actions(self, qs=''):
         r = TemplateIO(html=True)
         r += htmltext(' <li><a data-base-href="ods" href="ods%s">%s</a></li>') % (
@@ -1054,9 +1068,24 @@ class FormPage(Directory):
         r += htmltext('<ul id="sidebar-actions">')
         r += self.get_formdata_sidebar_actions(qs=qs)
         r += htmltext('</ul>')
+        views = list(self.get_custom_views())
+        if views:
+            r += htmltext('<h3>%s</h3>') % _('Custom Views')
+            r += htmltext('<ul id="sidebar-custom-views">')
+            view_type = 'map' if self.view_type == 'map' else ''
+            for view in views:
+                if self.view:
+                    active = bool(self.view.slug == view.slug)
+                    r += htmltext('<li class="active">' if active else '<li>')
+                    r += htmltext('<a href="../%s/%s">%s</a></li>') % (view.slug, view_type, view.title)
+                else:
+                    r += htmltext('<li><a href="%s/%s">%s</a></li>') % (view.slug, view_type, view.title)
+            r += htmltext('</ul>')
         return r.getvalue()
 
     def get_default_filters(self, mode):
+        if self.view:
+            return self.view.get_default_filters()
         if mode == 'listing':
             # enable status filter by default
             return ('status',)
@@ -1135,6 +1164,7 @@ class FormPage(Directory):
             FakeField('end', 'period-date', _('End')),
         ]
         default_filters = self.get_default_filters(mode)
+
         filter_fields = []
         for field in period_fake_fields + self.get_formdef_fields():
             field.enabled = False
@@ -1148,18 +1178,31 @@ class FormPage(Directory):
                 field.enabled = 'filter-%s' % field.id in get_request().form
             else:
                 field.enabled = (field.id in default_filters)
-                if field.type in ('item', 'items'):
+                if not self.view and field.type in ('item', 'items'):
                     field.enabled = field.in_filters
 
-        r += htmltext('<h3><span>%s</span> <span class="change">(<a id="filter-settings">%s</a>)</span></h3>' % (
-            _('Filters'), _('change')))
+        r += htmltext('<h3><span>%s</span>') % _('Options')
+        r += htmltext('<span class="change">(')
+        r += htmltext('<a id="filter-settings">%s</a>') % _('filters')
+        if self.view_type in ('table', 'map'):
+            if self.view_type == 'table':
+                columns_settings_labels = (_('Columns Settings'), _('columns'))
+            elif self.view_type == 'map':
+                columns_settings_labels = (_('Marker Settings'), _('markers'))
+            r += htmltext(' - <a id="columns-settings" title="%s">%s</a>') % columns_settings_labels
+        r += htmltext(')</span></h3>')
+
+        filters_dict = {}
+        if self.view:
+            filters_dict.update(self.view.get_filters_dict())
+        filters_dict.update(get_request().form)
 
         for filter_field in filter_fields:
             if not filter_field.enabled:
                 continue
 
             filter_field_key = 'filter-%s-value' % filter_field.id
-            filter_field_value = get_request().form.get(filter_field_key)
+            filter_field_value = filters_dict.get(filter_field_key)
 
             if filter_field.type == 'status':
                 r += htmltext('<div class="widget">')
@@ -1214,7 +1257,7 @@ class FormPage(Directory):
                         options.insert(0, (None, '', ''))
                         attrs = {'data-refresh-options': str(filter_field.id)}
                     else:
-                        current_filter = get_request().form.get('filter-%s-value' % filter_field.id)
+                        current_filter = filters_dict.get('filter-%s-value' % filter_field.id)
                         options = [(current_filter, '', current_filter or '')]
                         attrs = {'data-remote-options': str(filter_field.id)}
                         get_response().add_javascript(['jquery.js', '../../i18n.js', 'qommon.forms.js', 'select2.js'])
@@ -1263,7 +1306,7 @@ class FormPage(Directory):
         return r.getvalue()
 
     def get_fields_sidebar(self, selected_filter, fields, offset=None,
-            limit=None, order_by=None, columns_settings_label=None,
+            limit=None, order_by=None,
             query=None, criterias=None):
         get_response().add_javascript(['jquery.js', 'jquery-ui.js', 'wcs.listing.js'])
 
@@ -1292,30 +1335,115 @@ class FormPage(Directory):
 
         r += self.get_filter_sidebar(selected_filter=selected_filter, query=query, criterias=criterias)
 
-        r += htmltext('<button class="refresh">%s</button>') % _('Refresh')
-
-        if columns_settings_label:
-            r += htmltext('<button id="columns-settings">%s</button>') % columns_settings_label
+        r += htmltext('<button class="refresh" hidden>%s</button>') % _('Refresh')
 
+        if self.view_type in ('table', 'map'):
             # column settings dialog content
             r += htmltext('<div style="display: none;">')
-            r += htmltext('<ul id="columns-filter">')
-            for field in self.get_formdef_fields():
+            r += htmltext('<ul id="columns-filter" class="objects-list columns-filter">')
+            column_order = []
+            field_ids = [x.id for x in fields]
+
+            def get_column_position(x):
+                if x.id in field_ids:
+                    return field_ids.index(x.id)
+                return 9999
+
+            for field in sorted(self.get_formdef_fields(), key=get_column_position):
                 if not hasattr(field, str('get_view_value')):
                     continue
-                r += htmltext('<li><input type="checkbox" name="%s"') % field.id
-                if field.id in [x.id for x in fields]:
+                r += htmltext('<li><span class="handle">⣿</span><label><input type="checkbox" name="%s"') % field.id
+                if field.id in field_ids:
                     r += htmltext(' checked="checked"')
-                r += htmltext(' id="fields-column-%s"') % field.id
                 r += htmltext('/>')
-                r += htmltext('<label for="fields-column-%s">%s</label>') % (
-                        field.id, misc.ellipsize(field.label, 70))
+                r += htmltext('%s</label>') % misc.ellipsize(field.label, 70)
                 r += htmltext('</li>')
+                column_order.append(str(field.id))
             r += htmltext('</ul>')
             r += htmltext('</div>')
+            r += htmltext('<input type="hidden" name="columns-order" value="%s">' % ','.join(column_order))
         r += htmltext('</form>')
+
+        r += self.get_custom_view_form().render()
+        r += htmltext('<button id="save-view">%s</button>') % _('Save View')
+        if self.can_delete_view():
+            r += htmltext(' <a data-popup id="delete-view" href="./delete-view" class="button">%s</a>') % _('Delete View')
+
         return r.getvalue()
 
+    def get_custom_view_form(self):
+        form = Form(method='post', id='save-custom-view', hidden='hidden', action='save-view')
+        form.add(HiddenWidget, 'qs', value=get_request().get_query())
+        form.add(StringWidget, 'title', title=_('Title'), required=True,
+                value=self.view.title if self.view else None)
+        if get_publisher().get_backoffice_root().is_accessible(self.admin_permisison):
+            # admins can create views accessible to everyone
+            form.add(RadiobuttonsWidget, 'visibility', title=_('Visibility'),
+                    value=self.view.visibility if self.view else 'owner',
+                    options=[
+                        ('owner', _('to me only'), 'owner'),
+                        ('any', _('to any users'), 'any')
+                    ])
+        if self.view and (self.view.user_id == get_request().user.id or
+                          get_publisher().get_backoffice_root().is_accessible('forms')):
+            form.add(CheckboxWidget, 'update', title=_('Update existing view settings'), value=True)
+        form.add_submit('submit', _('Save View'))
+        form.add_submit('cancel', _('Cancel'))
+        return form
+
+    def save_view(self):
+        form = self.get_custom_view_form()
+        if form.get_widget('update') and form.get_widget('update').parse():
+            custom_view = self.view
+        else:
+            custom_view = get_publisher().custom_view_class()
+        custom_view.title = form.get_widget('title').parse()
+        if not custom_view.title:
+            get_session().message = ('error', _('Missing title.'))
+            return redirect('.')
+        custom_view.user = get_request().user
+        custom_view.formdef = self.formdef
+        custom_view.set_from_qs(form.get_widget('qs').parse())
+        if not custom_view.columns['list']:
+            get_session().message = ('error', _('Views must have at least one column.'))
+            return redirect('.')
+        if form.get_widget('visibility'):
+            custom_view.visibility = form.get_widget('visibility').parse()
+        custom_view.store()
+        if self.view:
+            return redirect('../' + custom_view.slug + '/')
+        else:
+            return redirect(custom_view.slug + '/')
+
+    def can_delete_view(self):
+        if not self.view:
+            return False
+        if str(self.view.user_id) == str(get_request().user.id):
+            return True
+        return get_publisher().get_backoffice_root().is_accessible(self.admin_permisison)
+
+    def delete_view(self):
+        if not self.can_delete_view():
+            raise errors.AccessForbiddenError()
+        form = Form(enctype='multipart/form-data')
+        form.widgets.append(HtmlWidget('<p>%s</p>' % _(
+            'You are about to remove the \"%s\" custom view.') % self.view.title))
+        if self.view.visibility == 'any':
+            form.widgets.append(HtmlWidget('<div class="warningnotice"<p>%s</p></div>' % _(
+                'Beware this view is available to all users, and will thus be removed for everyone.')))
+        form.add_submit('delete', _('Delete'))
+        form.add_submit('cancel', _('Cancel'))
+        if form.get_widget('cancel').parse():
+            return redirect('.')
+        if not form.is_submitted() or form.has_errors():
+            r = TemplateIO(html=True)
+            r += htmltext('<h2>%s</h2>') % (_('Delete Custom View'))
+            r += form.render()
+            return r.getvalue()
+        else:
+            self.view.remove_self()
+            return redirect('..')
+
     def get_formdef_fields(self):
         fields = []
         fields.append(FakeField('id', 'id', _('Number')))
@@ -1333,11 +1461,14 @@ class FormPage(Directory):
         return fields
 
     def get_default_columns(self):
-        field_ids = ['id', 'time', 'last_update_time', 'user-label']
-        for field in self.formdef.get_all_fields():
-            if hasattr(field, 'get_view_value') and field.include_in_listing:
-                field_ids.append(field.id)
-        field_ids.append('status')
+        if self.view:
+            field_ids = self.view.get_columns()
+        else:
+            field_ids = ['id', 'time', 'last_update_time', 'user-label']
+            for field in self.formdef.get_all_fields():
+                if hasattr(field, 'get_view_value') and field.include_in_listing:
+                    field_ids.append(field.id)
+            field_ids.append('status')
         return field_ids
 
     def get_fields_from_query(self, ignore_form=False):
@@ -1350,6 +1481,19 @@ class FormPage(Directory):
             if field.id in field_ids:
                 fields.append(field)
 
+        if 'columns-order' in get_request().form or self.view:
+            if ignore_form or 'columns-order' not in get_request().form:
+                field_order = field_ids
+            else:
+                field_order = get_request().form['columns-order'].split(',')
+
+            def field_position(x):
+                if x.id in field_order:
+                    return field_order.index(x.id)
+                return 9999
+
+            fields.sort(key=field_position)
+
         if not fields:
             return self.get_fields_from_query(ignore_form=True)
 
@@ -1358,6 +1502,10 @@ class FormPage(Directory):
     def get_filter_from_query(self, default='waiting'):
         if 'filter' in get_request().form:
             return get_request().form['filter']
+        if self.view:
+            view_filter = self.view.get_filter()
+            if view_filter:
+                return view_filter
         if self.formdef.workflow.possible_status:
             return default
         return 'all'
@@ -1371,6 +1519,12 @@ class FormPage(Directory):
         ]
         filter_fields = []
         criterias = []
+
+        filters_dict = {}
+        if self.view:
+            filters_dict.update(self.view.get_filters_dict())
+        filters_dict.update(get_request().form)
+
         for filter_field in period_fake_fields + self.get_formdef_fields():
             if filter_field.type not in ('item', 'bool', 'items', 'period-date'):
                 continue
@@ -1380,10 +1534,10 @@ class FormPage(Directory):
             if filter_field.varname:
                 # if this is a field with a varname and filter-%(varname)s is
                 # present in the query string, enable this filter.
-                if get_request().form.get('filter-%s' % filter_field.varname):
+                if filters_dict.get('filter-%s' % filter_field.varname):
                     filter_field_key = 'filter-%s' % filter_field.varname
 
-            if get_request().form.get('filter-%s' % filter_field.id):
+            if filters_dict.get('filter-%s' % filter_field.id):
                 # if there's a filter-%(id)s, it is used to enable the actual
                 # filter, and the value will be found in filter-%s-value.
                 filter_field_key = 'filter-%s-value' % filter_field.id
@@ -1392,7 +1546,7 @@ class FormPage(Directory):
                 # if there's not known filter key, skip.
                 continue
 
-            filter_field_value = get_request().form.get(filter_field_key)
+            filter_field_value = filters_dict.get(filter_field_key)
             if not filter_field_value:
                 continue
 
@@ -1449,6 +1603,7 @@ class FormPage(Directory):
         return mass_actions
 
     def _q_index(self):
+        self.view_type = 'table'
         self.check_access()
         get_logger().info('backoffice - form %s - listing' % self.formdef.name)
 
@@ -1467,8 +1622,11 @@ class FormPage(Directory):
         else:
             limit = get_request().form.get('limit', 0)
         offset = get_request().form.get('offset', 0)
-        order_by = get_request().form.get('order_by',
-            get_publisher().get_site_option('default-sort-order') or '-receipt_time')
+        order_by = get_request().form.get('order_by')
+        if self.view and not order_by:
+            order_by = self.view.order_by
+        if not order_by:
+            order_by = get_publisher().get_site_option('default-sort-order') or '-receipt_time'
         query = get_request().form.get('q')
 
         qs = ''
@@ -1510,10 +1668,11 @@ class FormPage(Directory):
             get_response().filter = {'raw': True}
             return table
 
-        html_top('management', '%s - %s' % (_('Listing'), self.formdef.name))
+        view_name = self.view.title if self.view else _('Listing')
+        html_top('management', '%s - %s' % (view_name, self.formdef.name))
         r = TemplateIO(html=True)
         r += htmltext('<div id="appbar">')
-        r += htmltext('<h2>%s - %s</h2>') % (self.formdef.name, _('Listing'))
+        r += htmltext('<h2>%s - %s</h2>') % (self.formdef.name, view_name)
         r += get_session().display_message()
         r += self.listing_top_actions()
         r += htmltext('</div>')
@@ -1526,8 +1685,7 @@ class FormPage(Directory):
         get_response().filter['sidebar'] = self.get_formdata_sidebar(qs) + \
                 self.get_fields_sidebar(selected_filter, fields, limit=limit,
                         query=query, criterias=criterias,
-                        offset=offset, order_by=order_by,
-                        columns_settings_label=_('Columns Settings'))
+                        offset=offset, order_by=order_by)
 
         return r.getvalue()
 
@@ -1841,6 +1999,8 @@ class FormPage(Directory):
         selected_filter = self.get_filter_from_query(default='all')
         criterias = self.get_criterias_from_query()
         order_by = get_request().form.get('order_by', None)
+        if self.view and not order_by:
+            order_by = self.view.order_by
         query = get_request().form.get('q') if not anonymise else None
         offset = None
         if 'offset' in get_request().form:
@@ -1872,7 +2032,9 @@ class FormPage(Directory):
                 'receipt_time': datetime.datetime(*filled.receipt_time[:6]),
                 'last_update_time': datetime.datetime(*filled.last_update_time[:6]),
             } for filled in items]
-        if isinstance(self.formdef, CardDef):
+        if isinstance(self.formdef, CardDef) or self.view:
+            # for cards and custom views return results in a dictionary, as it
+            # provides a better path for evolutions
             output = {'data': output}
         return json.dumps(output,
                 cls=misc.JSONEncoder)
@@ -1980,6 +2142,7 @@ class FormPage(Directory):
         return IcsDirectory()
 
     def map(self):
+        self.view_type = 'map'
         get_response().add_javascript(['qommon.map.js'])
         html_top('management', '%s - %s' % (_('Form'), self.formdef.name))
         r = TemplateIO(html=True)
@@ -1995,8 +2158,12 @@ class FormPage(Directory):
 
         fields = self.get_fields_from_query()
         selected_filter = self.get_filter_from_query()
-        get_response().filter['sidebar'] = self.get_fields_sidebar(selected_filter,
-                fields, columns_settings_label=_('Markers Settings'))
+
+        qs = ''
+        if get_request().get_query():
+            qs = '?' + get_request().get_query()
+        get_response().filter['sidebar'] = self.get_formdata_sidebar(qs) + \
+                self.get_fields_sidebar(selected_filter, fields)
 
         r += htmltext('<h2>%s - %s</h2>') % (self.formdef.name, _('Map'))
         r += htmltext('<div %s></div>' % ' '.join(['%s="%s"' % x for x in attrs.items()]))
@@ -2212,6 +2379,10 @@ class FormPage(Directory):
         if component == 'ics':
             return self.ics()
 
+        if not self.view:
+            for view in self.get_custom_views([Equal('slug', component)]):
+                return self.__class__(formdef=self.formdef, view=view)
+
         try:
             filled = self.formdef.data_class().get(component)
         except KeyError:
diff --git a/wcs/custom_views.py b/wcs/custom_views.py
new file mode 100644
index 000000000..0348ed408
--- /dev/null
+++ b/wcs/custom_views.py
@@ -0,0 +1,127 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2020  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+from django.utils.six.moves.urllib import parse as urlparse
+from quixote import get_publisher
+
+from wcs.carddef import CardDef
+from wcs.formdef import FormDef
+from wcs.qommon.storage import StorableObject
+from wcs.qommon.misc import simplify
+
+
+class CustomView(StorableObject):
+    _names = 'custom-views'
+
+    title = None
+    slug = None
+    user_id = None
+    visibility = 'owner'
+    formdef_type = None
+    formdef_id = None
+    columns = None
+    filters = None
+    order_by = None
+
+    @property
+    def user(self):
+        return get_publisher().user_class.get(self.user_id)
+
+    @user.setter
+    def user(self, value):
+        self.user_id = str(value.id)
+
+    @property
+    def formdef(self):
+        if self.formdef_type == 'formdef':
+            return FormDef.get(self.formdef_id)
+        else:
+            return CardDef.get(self.formdef_id)
+
+    @formdef.setter
+    def formdef(self, value):
+        self.formdef_id = str(value.id)
+        self.formdef_type = value.xml_root_node
+
+    def match(self, user, formdef):
+        if self.visibility == 'owner' and self.user_id != str(user.id):
+            return False
+        if self.formdef_type != formdef.xml_root_node:
+            return False
+        if self.formdef_id != str(formdef.id):
+            return False
+        return True
+
+    def set_from_qs(self, qs):
+        parsed_qs = urlparse.parse_qsl(qs)
+        self.columns = {
+            'list': [
+                {'id': key} for (key, value) in parsed_qs if value == 'on' and not key.startswith('filter-')
+            ],
+        }
+
+        columns_order = [x[1] for x in parsed_qs if x[0] == 'columns-order']
+        if columns_order:
+            field_order = columns_order[0].split(',')
+
+            def field_position(x):
+                if x['id'] in field_order:
+                    return field_order.index(x['id'])
+                return 9999
+
+            self.columns['list'].sort(key=field_position)
+
+        order_by = [x[1] for x in parsed_qs if x[0] == 'order_by']
+        if order_by:
+            self.order_by = order_by[0]
+
+        self.filters = {key: value for (key, value) in parsed_qs if key.startswith('filter')}
+
+    def ensure_slug(self):
+        if self.slug:
+            return
+        existing_slugs = {
+            x.slug: True
+            for x in self.select(ignore_errors=True)
+            if (x.user_id == self.user_id and x.visibility == 'owner' and self.visibility == 'owner')
+            and x.formdef_type == self.formdef_type
+            and x.formdef_id == self.formdef_id
+        }
+        base_slug = simplify(self.title)
+        self.slug = base_slug
+        i = 2
+        while self.slug in existing_slugs:
+            self.slug = '%s-%s' % (base_slug, i)
+            i += 1
+
+    def store(self, *args, **kwargs):
+        self.ensure_slug()
+        return super(CustomView, self).store(*args, **kwargs)
+
+    def get_columns(self):
+        if self.columns and 'list' in self.columns:
+            return [x['id'] for x in self.columns['list']]
+        else:
+            return []
+
+    def get_filter(self):
+        return self.filters.get('filter')
+
+    def get_filters_dict(self):
+        return self.filters
+
+    def get_default_filters(self):
+        return [key[7:] for key in self.filters if key.startswith('filter-')]
diff --git a/wcs/publisher.py b/wcs/publisher.py
index 8ef473ec3..72c97ce17 100644
--- a/wcs/publisher.py
+++ b/wcs/publisher.py
@@ -46,6 +46,7 @@ set_publisher_class(StubWcsPublisher)
 from .root import RootDirectory
 from .backoffice import RootDirectory as BackofficeRootDirectory
 from .admin import RootDirectory as AdminRootDirectory
+from . import custom_views
 from . import sessions
 from .qommon.cron import CronJob
 
@@ -148,11 +149,13 @@ class WcsPublisher(StubWcsPublisher):
             self.user_class = sql.SqlUser
             self.tracking_code_class = sql.TrackingCode
             self.session_class = sql.Session
+            self.custom_view_class = sql.CustomView
             sql.get_connection(new=True)
         else:
             self.user_class = User
             self.tracking_code_class = TrackingCode
             self.session_class = sessions.BasicSession
+            self.custom_view_class = custom_views.CustomView
 
         self.session_manager_class = sessions.StorageSessionManager
         self.set_session_manager(self.session_manager_class(session_class=self.session_class))
@@ -298,6 +301,7 @@ class WcsPublisher(StubWcsPublisher):
         sql.do_session_table()
         sql.do_user_table()
         sql.do_tracking_code_table()
+        sql.do_custom_views_table()
         sql.do_meta_table()
         from .formdef import FormDef
         from .carddef import CardDef
diff --git a/wcs/qommon/static/css/dc2/admin.css b/wcs/qommon/static/css/dc2/admin.css
index bc013e5e5..4f4d0ba8d 100644
--- a/wcs/qommon/static/css/dc2/admin.css
+++ b/wcs/qommon/static/css/dc2/admin.css
@@ -1084,15 +1084,37 @@ div.PrefillSelectionWidget div.content input[type=submit] {
 }
 
 ul#field-filter,
-ul#columns-filter {
+ul.columns-filter {
 	list-style: none;
 	padding-left: 0;
 	margin-left: 0;
+}
+
+ul#field-filter {
 	-webkit-column-count: 2;
 	-moz-column-count: 2;
 	column-count: 2;
 }
 
+ul.columns-filter span.handle {
+	padding: 0;
+	position: absolute;
+	width: 2em;
+	cursor: move;
+	display: inline-block;
+	padding: 0 0.5ex;
+	text-align: center;
+	width: 1em;
+}
+
+ul.columns-filter li {
+	padding-left: 0;
+}
+
+ul.columns-filter li label {
+	padding-left: 2em;
+}
+
 ul.multipage li {
 	margin-left: 2em;
 }
@@ -1293,6 +1315,7 @@ fieldset.form-plus.closed legend:after {
 	}
 }
 
+a#columns-settings,
 a#filter-settings {
 	cursor: pointer;
 }
@@ -1883,3 +1906,7 @@ div.mail-body {
 	margin-top: 1em;
 	white-space: pre-line;
 }
+
+#sidebar-custom-views .active {
+	font-weight: bold;
+}
diff --git a/wcs/qommon/static/js/wcs.listing.js b/wcs/qommon/static/js/wcs.listing.js
index c21c86502..408f72893 100644
--- a/wcs/qommon/static/js/wcs.listing.js
+++ b/wcs/qommon/static/js/wcs.listing.js
@@ -201,26 +201,22 @@ $(function() {
   /* column settings */
   $('#columns-settings').click(function() {
     var dialog = $('<form>');
-    $('#columns-filter').clone().appendTo(dialog);
-    $(dialog).find('input').each(function(idx, elem) {
-      $(this).attr('id', 'dlg-' + $(this).attr('id'));
-    });
-    $(dialog).find('label').each(function(idx, elem) {
-      $(this).attr('for', 'dlg-' + $(this).attr('for'));
-    });
+    var $dialog_filter = $('#columns-filter').clone().attr('id', null);
+    $dialog_filter.appendTo(dialog);
+    $dialog_filter.sortable({handle: '.handle'})
     $(dialog).dialog({
             modal: true,
             resizable: false,
-            title: $('#columns-settings').text(),
+            title: $('#columns-settings').attr('title'),
             width: '30em'});
     $(dialog).dialog('option', 'buttons', [
             {text: $('form#listing-settings button.refresh').text(),
              click: function() {
-                $(this).find('input[type="checkbox"]').each(function(idx, elem) {
-                  var checked = $(elem).prop('checked');
-                  $('form#listing-settings input[name="' + $(elem).attr('name') + '"]').attr('checked', checked);
-                  $('form#listing-settings input[name="' + $(elem).attr('name') + '"]').prop('checked', checked);
-                });
+                var $container = $('#columns-filter').parent();
+                $('#columns-filter').remove();
+                $dialog_filter.attr('id', 'columns-filter');
+                $dialog_filter.appendTo($container);
+                $('[name="columns-order"]').val($('#columns-filter input:checked').map(function() { return $(this).attr('name'); }).get().join());
                 $(this).dialog('close');
                 $('form#listing-settings').submit();
               }
@@ -302,6 +298,30 @@ $(function() {
     return false;
   });
 
+  $('button#save-view').on('click', function() {
+    var div_dialog = $('<div>');
+    $('#save-custom-view').clone().attr('hidden', null).appendTo(div_dialog);
+    $(div_dialog).find('[name=qs]').val($('form#listing-settings').serialize());
+    $(div_dialog).find('.buttons').hide();
+    var dialog = $(div_dialog).dialog({
+            modal: true,
+            resizable: false,
+            title: $(this).text(),
+            width: 'auto',
+            buttons: [
+              {text: $(div_dialog).find('.cancel-button').text(),
+               class: 'cancel-button',
+               click: function() { $(this).dialog('close'); }
+              },
+              {text: $(div_dialog).find('.submit-button').text(),
+               class: 'submit-button',
+               click: function() { $(div_dialog).find('.submit-button button').click(); return false; }
+              }
+            ]
+    });
+    return false;
+  });
+
   /* automatically refresh on filter change */
   $('form#listing-settings select').change(function() {
     $('form#listing-settings').submit();
diff --git a/wcs/sql.py b/wcs/sql.py
index 7e1cf76da..5286c30de 100644
--- a/wcs/sql.py
+++ b/wcs/sql.py
@@ -16,6 +16,7 @@
 
 import psycopg2
 import psycopg2.extensions
+import psycopg2.extras
 import datetime
 import time
 import re
@@ -38,6 +39,7 @@ from .publisher import UnpicklerClass
 
 import wcs.categories
 import wcs.carddata
+import wcs.custom_views
 import wcs.formdata
 import wcs.tracking_code
 import wcs.users
@@ -47,6 +49,10 @@ import wcs.users
 psycopg2.extensions.register_type(psycopg2.extensions.UNICODE)
 psycopg2.extensions.register_type(psycopg2.extensions.UNICODEARRAY)
 
+# automatically adapt dictionaries into json fields
+psycopg2.extensions.register_adapter(dict, psycopg2.extras.Json)
+
+
 SQL_TYPE_MAPPING = {
     'title': None,
     'subtitle': None,
@@ -754,6 +760,40 @@ def do_session_table():
     cur.close()
 
 
+def do_custom_views_table():
+    conn, cur = get_connection_and_cursor()
+    table_name = 'custom_views'
+
+    cur.execute('''SELECT COUNT(*) FROM information_schema.tables
+                    WHERE table_schema = 'public'
+                      AND table_name = %s''', (table_name,))
+    if cur.fetchone()[0] == 0:
+        cur.execute('''CREATE TABLE %s (id varchar PRIMARY KEY,
+                                        title varchar,
+                                        slug varchar,
+                                        user_id varchar,
+                                        visibility varchar,
+                                        formdef_type varchar,
+                                        formdef_id varchar,
+                                        order_by varchar,
+                                        columns jsonb,
+                                        filters jsonb
+                                        )''' % table_name)
+    cur.execute('''SELECT column_name FROM information_schema.columns
+                    WHERE table_schema = 'public'
+                      AND table_name = %s''', (table_name,))
+    existing_fields = set([x[0] for x in cur.fetchall()])
+
+    needed_fields = set([x[0] for x in CustomView._table_static_fields])
+
+    # delete obsolete fields
+    for field in (existing_fields - needed_fields):
+        cur.execute('''ALTER TABLE %s DROP COLUMN %s''' % (table_name, field))
+
+    conn.commit()
+    cur.close()
+
+
 @guard_postgres
 def do_meta_table(conn=None, cur=None, insert_current_sql_level=True):
     own_conn = False
@@ -2103,6 +2143,85 @@ class TrackingCode(SqlMixin, wcs.tracking_code.TrackingCode):
         return []
 
 
+class CustomView(SqlMixin, wcs.custom_views.CustomView):
+    _table_name = 'custom_views'
+    _table_static_fields = [
+        ('id', 'varchar'),
+        ('title', 'varchar'),
+        ('slug', 'varchar'),
+        ('user_id', 'varchar'),
+        ('visibility', 'varchar'),
+        ('formdef_type', 'varchar'),
+        ('formdef_id', 'varchar'),
+        ('order_by', 'varchar'),
+        ('columns', 'jsonb'),
+        ('filters', 'jsonb'),
+    ]
+
+    @guard_postgres
+    @invalidate_substitution_cache
+    def store(self):
+        self.ensure_slug()
+        sql_dict = {
+            'id': self.id,
+            'title': self.title,
+            'slug': self.slug,
+            'user_id': self.user_id,
+            'visibility': self.visibility,
+            'formdef_type': self.formdef_type,
+            'formdef_id': self.formdef_id,
+            'order_by': self.order_by,
+            'columns': self.columns,
+            'filters': self.filters,
+        }
+
+        conn, cur = get_connection_and_cursor()
+        if not self.id:
+            column_names = sql_dict.keys()
+            sql_dict['id'] = self.get_new_id()
+            sql_statement = '''INSERT INTO %s (%s)
+                               VALUES (%s)
+                               RETURNING id''' % (
+                                       self._table_name,
+                                       ', '.join(column_names),
+                                       ', '.join(['%%(%s)s' % x for x in column_names]))
+            while True:
+                try:
+                    cur.execute(sql_statement, sql_dict)
+                except psycopg2.IntegrityError:
+                    conn.rollback()
+                    sql_dict['id'] = self.get_new_id()
+                else:
+                    break
+            self.id = str_encode(cur.fetchone()[0])
+        else:
+            column_names = sql_dict.keys()
+            sql_dict['id'] = self.id
+            sql_statement = '''UPDATE %s SET %s WHERE id = %%(id)s RETURNING id''' % (
+                                       self._table_name,
+                                       ', '.join(['%s = %%(%s)s' % (x, x) for x in column_names]))
+            cur.execute(sql_statement, sql_dict)
+            if cur.fetchone() is None:
+                raise AssertionError()
+
+        conn.commit()
+        cur.close()
+
+    @classmethod
+    def _row2ob(cls, row):
+        o = cls()
+        for field, value in zip(cls._table_static_fields, tuple(row)):
+            if field[1] == 'varchar':
+                setattr(o, field[0], str_encode(value))
+            elif field[1] == 'jsonb':
+                setattr(o, field[0], value)
+        return o
+
+    @classmethod
+    def get_data_fields(cls):
+        return []
+
+
 class classproperty(object):
     def __init__(self, f):
         self.f = f
@@ -2333,7 +2452,7 @@ def get_yearly_totals(period_start=None, period_end=None, criterias=None):
     return result
 
 
-SQL_LEVEL = 36
+SQL_LEVEL = 37
 
 
 def migrate_global_views(conn, cur):
@@ -2464,6 +2583,9 @@ def migrate():
         # 25: create session_table
         # 32: add last_update_time column to session table
         do_session_table()
+    if sql_level < 37:
+        # 37: create custom_views tabl
+        do_custom_views_table()
     if sql_level < 30:
         # 30: actually remove evo.who on anonymised formdatas
         from wcs.formdef import FormDef
-- 
2.26.0

