Patches in the API can be filtered by labels using the 'labels'
query parameter.

Signed-off-by: Franciszek Stachura <[email protected]>
---
 docs/api/schemas/latest/patchwork.yaml   |   9 ++
 docs/api/schemas/patchwork.j2            |  11 ++
 docs/api/schemas/v1.4/patchwork.yaml     |   9 ++
 patchwork/api/filters.py                 |  20 ++++
 patchwork/tests/unit/api/test_patch.py   |  34 +++++++
 patchwork/tests/unit/views/test_patch.py | 123 +++++++++++++++++++++++
 patchwork/tests/utils.py                 |   8 ++
 7 files changed, 214 insertions(+)

diff --git a/docs/api/schemas/latest/patchwork.yaml 
b/docs/api/schemas/latest/patchwork.yaml
index 1b85e952..196a2515 100644
--- a/docs/api/schemas/latest/patchwork.yaml
+++ b/docs/api/schemas/latest/patchwork.yaml
@@ -578,6 +578,15 @@ paths:
           schema:
             title: ''
             type: string
+        - in: query
+          name: labels
+          description: |
+            List of labels assigned to queried patches, separated by ','.
+            If a label name begins with a '-', patches with that label won't
+            be included.
+          schema:
+            title: ''
+            type: string
       responses:
         '200':
           description: 'List of patches'
diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2
index cea28fc0..98e0845f 100644
--- a/docs/api/schemas/patchwork.j2
+++ b/docs/api/schemas/patchwork.j2
@@ -596,6 +596,17 @@ paths:
           schema:
             title: ''
             type: string
+{% endif %}
+{% if version >= (1, 4) %}
+        - in: query
+          name: labels
+          description: |
+            List of labels assigned to queried patches, separated by ','.
+            If a label name begins with a '-', patches with that label won't
+            be included.
+          schema:
+            title: ''
+            type: string
 {% endif %}
       responses:
         '200':
diff --git a/docs/api/schemas/v1.4/patchwork.yaml 
b/docs/api/schemas/v1.4/patchwork.yaml
index 359e8224..a63f18fb 100644
--- a/docs/api/schemas/v1.4/patchwork.yaml
+++ b/docs/api/schemas/v1.4/patchwork.yaml
@@ -578,6 +578,15 @@ paths:
           schema:
             title: ''
             type: string
+        - in: query
+          name: labels
+          description: |
+            List of labels assigned to queried patches, separated by ','.
+            If a label name begins with a '-', patches with that label won't
+            be included.
+          schema:
+            title: ''
+            type: string
       responses:
         '200':
           description: 'List of patches'
diff --git a/patchwork/api/filters.py b/patchwork/api/filters.py
index e332c531..9bd42254 100644
--- a/patchwork/api/filters.py
+++ b/patchwork/api/filters.py
@@ -26,6 +26,9 @@ from patchwork.models import Project
 from patchwork.models import Series
 from patchwork.models import State
 
+from patchwork.models import exclude_submissions_by_labels
+from patchwork.models import filter_submissions_by_labels
+
 
 # custom backend
 
@@ -175,6 +178,21 @@ def msgid_filter(queryset, name, value):
     return queryset.filter(**{name: '<' + value + '>'})
 
 
+def labels_filter(queryset, _, value):
+    label_names = value.split(',')
+
+    labels_pos, labels_neg = [], []
+    for label in label_names:
+        if not label.startswith('-'):
+            labels_pos.append(label)
+        else:
+            labels_neg.append(label[1:])
+
+    queryset = exclude_submissions_by_labels(queryset, labels_neg)
+    queryset = filter_submissions_by_labels(queryset, labels_pos)
+    return queryset
+
+
 class CoverFilterSet(TimestampMixin, BaseFilterSet):
     project = ProjectFilter(queryset=Project.objects.all(), distinct=False)
     # NOTE(stephenfin): We disable the select-based HTML widgets for these
@@ -206,6 +224,7 @@ class PatchFilterSet(TimestampMixin, BaseFilterSet):
     state = StateFilter(queryset=State.objects.all(), distinct=False)
     hash = CharFilter(lookup_expr='iexact')
     msgid = CharFilter(method=msgid_filter)
+    labels = CharFilter(method=labels_filter)
 
     class Meta:
         model = Patch
@@ -225,6 +244,7 @@ class PatchFilterSet(TimestampMixin, BaseFilterSet):
         )
         versioned_fields = {
             '1.2': ('hash', 'msgid'),
+            '1.4': ('labels'),
         }
 
 
diff --git a/patchwork/tests/unit/api/test_patch.py 
b/patchwork/tests/unit/api/test_patch.py
index 19f2af8f..1facf311 100644
--- a/patchwork/tests/unit/api/test_patch.py
+++ b/patchwork/tests/unit/api/test_patch.py
@@ -228,6 +228,40 @@ class TestPatchAPI(utils.APITestCase):
         resp = self.client.get(self.api_url(), {'msgid': '[email protected]'})
         self.assertEqual(0, len(resp.data))
 
+    def test_list_filter_labels(self):
+        """Filter patches by labels."""
+        label1 = create_label()
+        label2 = create_label()
+        label3 = create_label()
+
+        person_obj = create_person(email='[email protected]')
+        project_obj = create_project(linkname='myproject')
+        state_obj = create_state(name='Under Review')
+        patch_kwargs = {
+            'state': state_obj,
+            'project': project_obj,
+            'submitter': person_obj,
+        }
+        patch_no_labels = create_patch(**patch_kwargs)
+        patch1 = create_patch(**patch_kwargs, labels=[label1])
+        patch2 = create_patch(**patch_kwargs, labels=[label1, label2])
+
+        resp = self.client.get(self.api_url(), {'labels': label1.name})
+        self.assertEqual([patch1.id, patch2.id], [x['id'] for x in resp.data])
+
+        resp = self.client.get(self.api_url(), {'labels': label3.name})
+        self.assertEqual(0, len(resp.data))
+
+        resp = self.client.get(self.api_url(), {'labels': '-' + label2.name})
+        self.assertEqual(
+            [patch_no_labels.id, patch1.id], [x['id'] for x in resp.data]
+        )
+
+        resp = self.client.get(
+            self.api_url(), {'labels': label1.name + ',' + label2.name}
+        )
+        self.assertEqual([patch2.id], [x['id'] for x in resp.data])
+
     @utils.store_samples('patch-list-1-0')
     def test_list_version_1_0(self):
         """List patches using API v1.0."""
diff --git a/patchwork/tests/unit/views/test_patch.py 
b/patchwork/tests/unit/views/test_patch.py
index 133bae36..983cf85b 100644
--- a/patchwork/tests/unit/views/test_patch.py
+++ b/patchwork/tests/unit/views/test_patch.py
@@ -23,6 +23,7 @@ from patchwork.tests.utils import create_patch_comment
 from patchwork.tests.utils import create_patches
 from patchwork.tests.utils import create_person
 from patchwork.tests.utils import create_project
+from patchwork.tests.utils import create_label
 from patchwork.tests.utils import create_state
 from patchwork.tests.utils import create_user
 from patchwork.tests.utils import read_patch
@@ -203,6 +204,128 @@ class PatchListFilteringTest(TestCase):
         self.assertEqual(response.status_code, 200)
 
 
+class PatchListLabelFilteringTest(TestCase):
+    def setUp(self):
+        self.project = create_project()
+        self.label1 = create_label(project=self.project)
+        self.label2 = create_label(project=self.project)
+        self.global_label = create_label(project=None)
+
+        person = create_person(name='test', email='[email protected]')
+
+        self.patches = [
+            create_patch(submitter=person, project=self.project, labels=[]),
+            create_patch(
+                submitter=person, project=self.project, labels=[self.label1]
+            ),
+            create_patch(
+                submitter=person,
+                project=self.project,
+                labels=[self.label1, self.label2],
+            ),
+            create_patch(
+                submitter=person,
+                project=self.project,
+                labels=[self.label1, self.global_label],
+            ),
+        ]
+
+    def _extract_patch_ids(self, response):
+        id_re = re.compile(r'<tr id="patch-row:(\d+)"')
+        ids = [
+            int(m.group(1)) for m in id_re.finditer(response.content.decode())
+        ]
+
+        return ids
+
+    def _extract_patches(self, response):
+        ids = self._extract_patch_ids(response)
+        if not ids:
+            return []
+        return [Patch.objects.get(id=i) for i in ids]
+
+    def test_no_labels(self):
+        url = reverse(
+            'patch-list', kwargs={'project_id': self.project.linkname}
+        )
+        response = self.client.get(url + '?labels=')
+
+        patches = self._extract_patches(response)
+        self.assertEqual(len(patches), len(self.patches))
+
+    def test_one_label(self):
+        url = reverse(
+            'patch-list', kwargs={'project_id': self.project.linkname}
+        )
+        response = self.client.get(url + '?labels=' + self.label1.name)
+
+        patches = self._extract_patches(response)
+        self.assertEqual(len(patches), 3)
+        self.assertIn(self.patches[1], patches)
+        self.assertIn(self.patches[2], patches)
+        self.assertIn(self.patches[3], patches)
+
+    def test_two_labels(self):
+        url = reverse(
+            'patch-list', kwargs={'project_id': self.project.linkname}
+        )
+        response = self.client.get(
+            url + '?labels=' + self.label1.name + '+' + self.label2.name
+        )
+
+        patches = self._extract_patches(response)
+        self.assertEqual(len(patches), 1)
+        self.assertIn(self.patches[2], patches)
+
+    def test_global_label(self):
+        url = reverse(
+            'patch-list', kwargs={'project_id': self.project.linkname}
+        )
+        response = self.client.get(
+            url + '?labels=' + self.label1.name + '+' + self.global_label.name
+        )
+
+        patches = self._extract_patches(response)
+        self.assertEqual(len(patches), 1)
+        self.assertIn(self.patches[3], patches)
+
+    def test_negative_label(self):
+        url = reverse(
+            'patch-list', kwargs={'project_id': self.project.linkname}
+        )
+        response = self.client.get(url + '?labels=-' + self.label2.name)
+
+        patches = self._extract_patches(response)
+        self.assertEqual(len(patches), 3)
+        self.assertIn(self.patches[0], patches)
+        self.assertIn(self.patches[1], patches)
+        self.assertIn(self.patches[3], patches)
+
+    def test_positive_negative_label(self):
+        url = reverse(
+            'patch-list', kwargs={'project_id': self.project.linkname}
+        )
+        response = self.client.get(
+            url + '?labels=-' + self.label2.name + '+' + self.label1.name
+        )
+
+        patches = self._extract_patches(response)
+        self.assertEqual(len(patches), 2)
+        self.assertIn(self.patches[1], patches)
+        self.assertIn(self.patches[3], patches)
+
+    def test_mutually_exclusive_labels(self):
+        url = reverse(
+            'patch-list', kwargs={'project_id': self.project.linkname}
+        )
+        response = self.client.get(
+            url + '?labels=-' + self.label1.name + '+' + self.label1.name
+        )
+
+        patches = self._extract_patches(response)
+        self.assertEqual(len(patches), 0)
+
+
 class PatchViewTest(TestCase):
     def test_redirect(self):
         patch = create_patch()
diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py
index d4d5029f..79d0ab3a 100644
--- a/patchwork/tests/utils.py
+++ b/patchwork/tests/utils.py
@@ -198,12 +198,20 @@ def create_patch(**kwargs):
     }
     values.update(kwargs)
 
+    labels = None
+    if 'labels' in values:
+        labels = values['labels']
+        del values['labels']
+
     patch = Patch.objects.create(**values)
 
     if series:
         number = number or series.patches.count() + 1
         series.add_patch(patch, number)
 
+    if labels is not None:
+        patch.labels.set(labels)
+
     return patch
 
 
-- 
2.55.0

_______________________________________________
Patchwork mailing list
[email protected]
https://lists.ozlabs.org/listinfo/patchwork

Reply via email to