From bf1205f7d25c263c35c51299f05b62bf35dbd0ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Mon, 7 Feb 2022 16:01:01 +0100
Subject: [PATCH 3/3] backoffice: add filter on user function (#58881)

---
 tests/api/test_carddef.py              | 104 +++++++++++++++++++++++++
 tests/backoffice_pages/test_filters.py |  57 ++++++++++++++
 wcs/backoffice/management.py           |  55 ++++++++++++-
 wcs/qommon/storage.py                  |  18 +++++
 wcs/sql.py                             |  15 ++++
 5 files changed, 247 insertions(+), 2 deletions(-)

diff --git a/tests/api/test_carddef.py b/tests/api/test_carddef.py
index ed6268336..38d8e9c60 100644
--- a/tests/api/test_carddef.py
+++ b/tests/api/test_carddef.py
@@ -1091,3 +1091,107 @@ def test_card_parent_form_url(pub, local_user):
         'backoffice_url': 'http://example.net/backoffice/management/test/%s/' % formdata.id,
         'api_url': 'http://example.net/api/forms/test/%s/' % formdata.id,
     }
+
+
+def test_cards_filter_function(pub, local_user):
+    pub.role_class.wipe()
+    role = pub.role_class(name='test')
+    role.store()
+    local_user.roles = [role.id]
+    local_user.name_identifiers = ['0123456789']
+    local_user.store()
+
+    user1 = pub.user_class(name='userA')
+    user1.name_identifiers = ['56789']
+    user1.store()
+    user2 = pub.user_class(name='userB')
+    user2.name_identifiers = ['98765']
+    user2.store()
+
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'test'
+    carddef.fields = []
+    carddef.workflow_roles = {'_viewer': role.id}
+    carddef.digest_templates = {'default': 'bla {{ form_number }} xxx'}
+    carddef.store()
+
+    carddef.data_class().wipe()
+
+    carddatas = []
+    for i in range(3):
+        carddatas.append(carddef.data_class()())
+
+    carddatas[0].workflow_roles = {'_foobar': [str(role.id)]}
+    carddatas[1].workflow_roles = {'_foobar': ['_user:%s' % user1.id]}
+    carddatas[2].workflow_roles = {'_foobar': ['_user:%s' % user1.id, '_user:%s' % user2.id]}
+
+    for carddata in carddatas:
+        carddata.just_created()
+        carddata.jump_status('recorded')
+        carddata.store()
+
+    # no paramater, -> get everything
+    resp = get_app(pub).get(sign_uri('/api/cards/test/list'))
+    assert len(resp.json['data']) == 3
+
+    # filter on missing uuid
+    resp = get_app(pub).get(
+        sign_uri('/api/cards/test/list?filter-user-function=_foobar&filter-user-uuid=XXX')
+    )
+    assert len(resp.json['data']) == 0
+
+    resp = get_app(pub).get(
+        sign_uri(
+            '/api/cards/test/list?filter-user-function=_foobar&filter-user-uuid=%s'
+            % user1.name_identifiers[0]
+        )
+    )
+    assert len(resp.json['data']) == 2
+
+    resp = get_app(pub).get(
+        sign_uri(
+            '/api/cards/test/list?filter-user-function=_foobar&filter-user-uuid=%s'
+            % user2.name_identifiers[0]
+        )
+    )
+    assert len(resp.json['data']) == 1
+
+    # filter on role
+    resp = get_app(pub).get(
+        sign_uri(
+            '/api/cards/test/list?filter-user-function=_foobar&filter-user-uuid=%s'
+            % local_user.name_identifiers[0]
+        )
+    )
+    assert len(resp.json['data']) == 1
+
+    # via custom view
+    pub.custom_view_class.wipe()
+    custom_view = pub.custom_view_class()
+    custom_view.title = 'shared carddef custom view'
+    custom_view.formdef = carddef
+    custom_view.columns = {'list': [{'id': '0'}]}
+    custom_view.filters = {"filter-user-function": "on", "filter-user-function-value": "_foobar"}
+    custom_view.visibility = 'any'
+    custom_view.store()
+
+    resp = get_app(pub).get(
+        sign_uri(
+            '/api/cards/test/list/shared-carddef-custom-view?filter-user-uuid=%s' % user1.name_identifiers[0]
+        )
+    )
+    assert len(resp.json['data']) == 2
+
+    # with function obtained via role set on carddef
+    carddef.workflow_roles = {'_foobar': role.id}
+    carddef.store()
+    carddef.data_class().rebuild_security()
+
+    resp = get_app(pub).get(
+        sign_uri(
+            '/api/cards/test/list/shared-carddef-custom-view?filter-user-uuid=%s'
+            % local_user.name_identifiers[0]
+        )
+    )
+    assert len(resp.json['data']) == 3
diff --git a/tests/backoffice_pages/test_filters.py b/tests/backoffice_pages/test_filters.py
index 9515d14d3..c5dd826dc 100644
--- a/tests/backoffice_pages/test_filters.py
+++ b/tests/backoffice_pages/test_filters.py
@@ -896,6 +896,63 @@ def test_backoffice_submission_agent_filter(pub):
     assert resp.text.count('>userB<') == 0
 
 
+def test_workflow_function_filter(pub):
+    pub.user_class.wipe()
+    user = create_superuser(pub)
+    user.name_identifiers = ['0123456789']
+    pub.role_class.wipe()
+    role = pub.role_class(name='test')
+    role.store()
+
+    workflow = Workflow.get_default_workflow()
+    workflow.id = '2'
+    workflow.roles['_foobar'] = 'Foobar'
+    workflow.store()
+
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'form-title'
+    formdef.fields = []
+    formdef.workflow = workflow
+    formdef.workflow_roles = {'_receiver': role.id}
+    formdef.store()
+
+    user1 = pub.user_class(name='userA')
+    user1.name_identifiers = ['56789']
+    user1.store()
+    user2 = pub.user_class(name='userB')
+    user2.name_identifiers = ['98765']
+    user2.store()
+
+    data_class = formdef.data_class()
+    data_class.wipe()
+
+    formdatas = []
+    for i in range(3):
+        formdatas.append(data_class())
+
+    formdatas[0].workflow_roles = {'_foobar': ['_user:%s' % user.id]}
+    formdatas[1].workflow_roles = {'_foobar': ['_user:%s' % user1.id]}
+    formdatas[2].workflow_roles = {'_foobar': ['_user:%s' % user1.id, '_user:%s' % user2.id]}
+
+    for formdata in formdatas:
+        formdata.just_created()
+        formdata.jump_status('new')
+        formdata.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/management/form-title/')
+    # enable user-function column
+    resp.forms['listing-settings']['filter-user-function'].checked = True
+    resp = resp.forms['listing-settings'].submit()
+    assert resp.text.count('<tr') == 4
+
+    # set a value in the select field
+    resp.forms['listing-settings']['filter-user-function-value'].value = '_foobar'
+    resp = resp.forms['listing-settings'].submit()
+    assert resp.text.count('<tr') == 2
+
+
 def test_backoffice_table_varname_filter(pub):
     pub.user_class.wipe()
     create_superuser(pub)
diff --git a/wcs/backoffice/management.py b/wcs/backoffice/management.py
index e13375982..4d1905bab 100644
--- a/wcs/backoffice/management.py
+++ b/wcs/backoffice/management.py
@@ -67,6 +67,7 @@ from ..qommon.form import (
 from ..qommon.misc import C_, ellipsize, get_type_name
 from ..qommon.storage import (
     Contains,
+    ElementIntersects,
     Equal,
     FtsMatch,
     GreaterOrEqual,
@@ -1024,6 +1025,7 @@ class FormPage(Directory):
             'number',
             'period-date',
             'user-id',
+            'user-function',
             'submission-agent-id',
         ]
         if get_publisher().is_using_postgresql():
@@ -1038,6 +1040,7 @@ class FormPage(Directory):
             FakeField('start', 'period-date', _('Start')),
             FakeField('end', 'period-date', _('End')),
             FakeField('user', 'user-id', _('User')),
+            FakeField('user-function', 'user-function', _('Current User Function')),
             FakeField('submission-agent', 'submission-agent-id', _('Submission Agent'), addable=False),
         ]
         default_filters = self.get_default_filters(mode)
@@ -1175,6 +1178,18 @@ class FormPage(Directory):
                     widget._parsed = True  # make sure value is not replaced by request query
                     r += widget.render()
 
+            elif filter_field.type == 'user-function':
+                options = [('', '', '')] + [
+                    (x[0], x[1], x[0]) for x in self.formdef.workflow.get_sorted_functions()
+                ]
+                r += SingleSelectWidget(
+                    filter_field_key,
+                    title=filter_field.label,
+                    options=options,
+                    value=filter_field_value,
+                    render_br=False,
+                ).render()
+
             elif filter_field.type in ('item', 'items'):
                 filter_field.required = False
 
@@ -1647,6 +1662,7 @@ class FormPage(Directory):
             FakeField('start-mtime', 'period-date', _('Start (modification time)')),
             FakeField('end-mtime', 'period-date', _('End (modification time)')),
             FakeField('user', 'user-id', _('User')),
+            FakeField('user-function', 'user-function', _('Current User Function')),
             FakeField('submission-agent', 'submission-agent-id', _('Submission Agent')),
         ]
         criterias = []
@@ -1693,8 +1709,10 @@ class FormPage(Directory):
             if filter_field.type == 'number' and filters_dict.get('filter-number'):
                 filters_dict['filter-number-value'] = filters_dict['filter-number']
 
-            if filter_field.type == 'user-id':
-                # convert uuid based filter into local id filter
+            if filter_field.type == 'user-id' and not filters_dict.get('filter-user-function'):
+                # convert uuid based filter into local id filter.
+                # do not apply if there's filter-user-function as it indicates the filtering
+                # should happen on function, not ownership.
                 name_id = filters_dict.get('filter-user-uuid')
                 if name_id:
                     nameid_users = get_publisher().user_class.get_users_with_name_identifier(name_id)
@@ -1710,6 +1728,11 @@ class FormPage(Directory):
                             '__current__' if name_id == '__current__' else '-1'
                         )
 
+            if filter_field.type == 'user-function' and filters_dict.get('filter-user-function'):
+                if filters_dict.get('filter-user-function') != 'on':
+                    # allow for short form, with a single query parameter
+                    filters_dict['filter-user-function-value'] = filters_dict.get('filter-user-function')
+
             if filter_field.type == 'submission-agent-id':
                 # convert uuid based filter into local id filter
                 name_id = filters_dict.get('filter-submission-agent-uuid')
@@ -1741,6 +1764,21 @@ class FormPage(Directory):
             if not filter_field_value:
                 continue
 
+            if filter_field.type == 'user-function':
+                # .../list?filter-user-function=on&filter-user-function-value=_manager&filter-user-uuid=c3...
+                # .../list?filter-user-function=_manager&filter-user-uuid=c3...
+                name_id = filters_dict.get('filter-user-uuid')
+                if name_id:
+                    nameid_users = get_publisher().user_class.get_users_with_name_identifier(name_id)
+                    if nameid_users:
+                        filter_field_value = '%s:%s' % (
+                            filters_dict['filter-user-function-value'],
+                            nameid_users[0].id,
+                        )
+                    else:
+                        # no user with this uuid, change to filter on nobody
+                        filter_field_value = '%s:__none__' % (filters_dict['filter-user-function-value'],)
+
             if filter_field.type == 'date':
                 try:
                     filter_field_value = misc.get_as_datetime(filter_field_value).date().strftime('%Y-%m-%d')
@@ -1786,6 +1824,19 @@ class FormPage(Directory):
                 criterias.append(Equal('user_id', filter_field_value))
             elif filter_field.type == 'submission-agent-id':
                 criterias.append(Equal('submission_agent_id', filter_field_value))
+            elif filter_field.type == 'user-function':
+                if ':' in filter_field_value:
+                    filter_field_value, user_id = filter_field_value.split(':', 1)
+                    user_object = None if user_id == '__none__' else get_publisher().user_class().get(user_id)
+                else:
+                    user_object = get_request().user
+                criterias.append(
+                    ElementIntersects(
+                        'workflow_merged_roles_dict',
+                        filter_field_value,
+                        user_object.get_roles() if user_object else None,
+                    )
+                )
             elif filter_field.type in ('item', 'items'):
                 if filter_field.type == 'item':
                     criterias.append(Equal('f%s' % filter_field.id, filter_field_value))
diff --git a/wcs/qommon/storage.py b/wcs/qommon/storage.py
index c25ed828e..eb7774e9f 100644
--- a/wcs/qommon/storage.py
+++ b/wcs/qommon/storage.py
@@ -325,6 +325,24 @@ class ElementILike(Criteria):
         )
 
 
+class ElementIntersects(Criteria):
+    def __init__(self, attribute, key, value):
+        self.attribute = attribute
+        self.key = key
+        self.value = value
+        if not self.value:
+            self.value = set()
+        elif not isinstance(self.value, (tuple, list, set)):
+            self.value = {self.value}
+        else:
+            self.value = set(self.value)
+
+    def build_lambda(self):
+        return lambda x: self.value.intersection(
+            set((getattr(x, self.attribute, None) or {}).get(self.key, []))
+        )
+
+
 def parse_clause(clause):
     # creates a callable out of a clause
     #  (attribute, operator, value)
diff --git a/wcs/sql.py b/wcs/sql.py
index 22b21b465..2b622754a 100644
--- a/wcs/sql.py
+++ b/wcs/sql.py
@@ -313,6 +313,21 @@ class ElementILike(ElementEqual):
         return "%s->>'%s' ILIKE %%(c%s)s" % (self.attribute, self.key, id(self.value))
 
 
+class ElementIntersects(ElementEqual):
+    def as_sql(self):
+        if not self.value:
+            return 'FALSE'
+        if not isinstance(self.value, (tuple, list, set)):
+            self.value = [self.value]
+        else:
+            self.value = list(self.value)
+        return "EXISTS(SELECT 1 FROM jsonb_array_elements_text(%s->'%s') foo WHERE foo = ANY(%%(c%s)s))" % (
+            self.attribute,
+            self.key,
+            id(self.value),
+        )
+
+
 def get_name_as_sql_identifier(name):
     name = qommon.misc.simplify(name)
     for char in '<>|{}!?^*+/=\'':  # forbidden chars
-- 
2.34.1

