changeset 7854:171ff2e487df

Add @group for grouping in rest interface. Helpful for using optgroup in select boxes.
author John Rouillard <rouilj@ieee.org>
date Mon, 01 Apr 2024 14:42:36 -0400
parents 03c1b7ae3a68
children 047c02dfa267
files CHANGES.txt doc/rest.txt roundup/rest.py test/rest_common.py
diffstat 4 files changed, 121 insertions(+), 1 deletions(-) [+]
line wrap: on
line diff
--- a/CHANGES.txt	Mon Apr 01 09:57:16 2024 -0400
+++ b/CHANGES.txt	Mon Apr 01 14:42:36 2024 -0400
@@ -161,6 +161,8 @@
   jinja function. expandfile allows setting a dictionary and tokens in
   the file of the form "%(token_name)s" will be replaced in the file
   with the values from the dict. (John Rouillard)
+- add @group to rest interface collection queries. Useful when using
+  optgroup in select elements. (John Rouillard)
 
 2023-07-13 2.3.0
 
--- a/doc/rest.txt	Mon Apr 01 09:57:16 2024 -0400
+++ b/doc/rest.txt	Mon Apr 01 14:42:36 2024 -0400
@@ -650,6 +650,29 @@
 
     @sort=status,-id
 
+Grouping
+~~~~~~~~
+
+Collection endpoints support grouping. This is controlled by
+specifying a ``@group`` parameter with a list of properties of
+the searched class.  Optionally properties can include a sign
+('+' or '-') to specify the groups are sorted in ascending or
+descending order, respectively. If no sign is given, the groups
+are returned in ascending order. The following example would
+return the issues grouped by status (in order from
+unread->reolved) then within each status, by priority in
+descending order (wish -> critical)::
+
+    @group=status,-priority
+
+Adding ``@fields=status,priority`` to the query will allow you to see
+the status and priority values change so you can identify the items in
+each group.
+
+If combined with ``@sort=-id`` within each group he items would be
+sorted in descending order by id.
+
+This is useful for select elements that use optgroup.
 
 Pagination
 ~~~~~~~~~~
--- a/roundup/rest.py	Mon Apr 01 09:57:16 2024 -0400
+++ b/roundup/rest.py	Mon Apr 01 14:42:36 2024 -0400
@@ -804,6 +804,7 @@
         verbose = 1
         display_props = set()
         sort = []
+        group = []
         for form_field in input.value:
             key = form_field.name
             value = form_field.value
@@ -841,6 +842,29 @@
                         raise (Unauthorised(
                             'User does not have search permission on "%s.%s"'
                             % (class_name, pn)))
+            elif key == "@group":
+                f = value.split(",")
+                for p in f:
+                    if not p:
+                        raise UsageError("Empty property "
+                                         "for class %s." % (class_name))
+                    if p[0] in ('-', '+'):
+                        pn = p[1:]
+                        ss = p[0]
+                    else:
+                        ss = '+'
+                        pn = p
+                    # Only include properties where we have search permission
+                    # Note that hasSearchPermission already returns 0 for
+                    # non-existing properties.
+                    if self.db.security.hasSearchPermission(
+                        uid, class_name, pn
+                    ):
+                        group.append((ss, pn))
+                    else:
+                        raise (Unauthorised(
+                            'User does not have search permission on "%s.%s"'
+                            % (class_name, pn)))
             elif key.startswith("@"):
                 # ignore any unsupported/previously handled control key
                 # like @apiver
@@ -912,6 +936,8 @@
         kw = {}
         if sort:
             l.append(sort)
+        if group:
+            l.append(group)
         if exact_props:
             kw['exact_match_spec'] = exact_props
         if page['size'] is None:
--- a/test/rest_common.py	Mon Apr 01 09:57:16 2024 -0400
+++ b/test/rest_common.py	Mon Apr 01 14:42:36 2024 -0400
@@ -316,7 +316,7 @@
         except ValueError:
             pass
 
-    def create_sampledata(self):
+    def create_sampledata(self, data_max=3):
         """ Create sample data common to some test cases
         """
         self.create_stati()
@@ -338,6 +338,34 @@
             priority=self.db.priority.lookup('critical')
         )
 
+        if data_max > 10:
+            raise ValueError('data_max must be less than 10')
+
+        if data_max == 3:
+            return
+
+        sample_data = [
+             ["foo6", "normal", "closed"],
+             ["foo7", "critical", "open"],
+             ["foo8", "normal", "open"],
+             ["foo9", "critical", "open"],
+             ["foo10", "normal", "closed"],
+             ["foo11", "critical", "open"],
+             ["foo12", "normal", "closed"],
+             ["foo13", "normal", "open"],
+            
+        ]
+
+        for title, priority, status in sample_data:
+            new_issue = self.db.issue.create(
+                title=title,
+                status=self.db.status.lookup(status),
+                priority=self.db.priority.lookup(priority)
+            )
+
+            if int(new_issue) == data_max:
+                break
+
     def test_no_next_link_on_full_last_page(self):
         """Make sure that there is no next link
            on the last page where the total number of entries
@@ -971,6 +999,47 @@
         results = self.server.get_collection('issue', form)
         self.assertDictEqual(expected, results)
 
+    def testGrouping(self):
+        self.maxDiff = 4000
+        self.create_sampledata(data_max=5)
+        self.db.issue.set('1', status='7', priority='4')
+        self.db.issue.set('2', status='2', priority='4')
+        self.db.issue.set('3', status='2', priority='4')
+        self.db.issue.set('4', status='2', priority='2')
+        self.db.issue.set('5', status='2', priority='2')
+        self.db.commit()
+        base_path = self.db.config['TRACKER_WEB'] + 'rest/data/issue/'
+        # change some data for sorting on later
+        form = cgi.FieldStorage()
+        form.list = [
+            cgi.MiniFieldStorage('@fields', 'status,priority'),
+            cgi.MiniFieldStorage('@sort', '-id'),
+            cgi.MiniFieldStorage('@group', '-status,priority'),
+            cgi.MiniFieldStorage('@verbose', '0')
+        ]
+
+        # status is sorted by orderprop (property 'order')
+        expected={'data': {
+            '@total_size': 5,
+            'collection': [
+                {'link': base_path + '1', 'priority': '4',
+                 'status': '7', 'id': '1'},
+                {'link': base_path + '5',  'priority': '2',
+                 'status': '2', 'id': '5'},
+                {'link': base_path + '4',  'priority': '2',
+                 'status': '2', 'id': '4'},
+                {'link': base_path + '3',  'priority': '4',
+                 'status': '2', 'id': '3'},
+                {'link': base_path + '2',  'priority': '4',
+                 'status': '2', 'id': '2'},
+            ]
+        }}
+
+
+        results = self.server.get_collection('issue', form)
+        print(results)
+        self.assertDictEqual(expected, results)
+
     def testTransitiveField(self):
         """ Test a transitive property in @fields """
         base_path = self.db.config['TRACKER_WEB'] + 'rest/data/'

Roundup Issue Tracker: http://roundup-tracker.org/