Skip to content

Commit 18867b3

Browse files
committed
filters: Add label query filter
The filter allows filtering by project-specific and global labels. It also supports negative filtering. Prepending a label name with a minus sign excludes patches with that label from search results.
1 parent 437d670 commit 18867b3

3 files changed

Lines changed: 208 additions & 3 deletions

File tree

‎patchwork/filters.py‎

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -519,13 +519,71 @@ def set_status(self, *args, **kwargs):
519519
self.forced = True
520520

521521

522+
class LabelsFilter(Filter):
523+
name = 'Labels'
524+
param = 'labels'
525+
526+
def __init__(self, filters):
527+
super(LabelsFilter, self).__init__(filters)
528+
self.labels = None
529+
530+
@property
531+
def condition(self):
532+
return self.labels
533+
534+
@property
535+
def key(self):
536+
return self.labels
537+
538+
@key.setter
539+
def key(self, key):
540+
key = key.strip()
541+
if not key:
542+
return
543+
544+
self.labels = key
545+
self.applied = True
546+
547+
@property
548+
def kwargs(self):
549+
return None
550+
551+
def q(self, queryset):
552+
label_names = self.labels.split(' ')
553+
if len(label_names) == 0:
554+
return queryset
555+
556+
labels_pos, labels_neg = [], []
557+
for label in label_names:
558+
if not label.startswith('-'):
559+
labels_pos.append(label)
560+
else:
561+
labels_neg.append(label[1:])
562+
563+
queryset = queryset.exclude_labels(labels_neg)
564+
queryset = queryset.include_labels(labels_pos)
565+
return queryset
566+
567+
@property
568+
def form(self):
569+
value = ''
570+
if self.labels:
571+
value = escape(self.labels)
572+
# labels value escaped above
573+
return mark_safe( # noqa: S308
574+
'<input type="text" id="labels_input" name="%s" '
575+
'class="form-control" value="%s">' % (self.param, value)
576+
)
577+
578+
522579
FILTERS = [
523580
SeriesFilter,
524581
SubmitterFilter,
525582
StateFilter,
526583
SearchFilter,
527584
ArchiveFilter,
528585
DelegateFilter,
586+
LabelsFilter,
529587
]
530588

531589

@@ -555,14 +613,22 @@ def available_filters(self):
555613

556614
def apply(self, queryset):
557615
kwargs = collections.OrderedDict()
616+
q_filters = []
558617
for f in self._filters:
559618
if f.applied:
560-
kwargs.update(f.kwargs)
619+
if f.kwargs is not None:
620+
kwargs.update(f.kwargs)
621+
else:
622+
q_filters.append(f)
561623

562-
if not kwargs:
624+
if not kwargs and not q_filters:
563625
return queryset
564626

565-
return queryset.filter(**kwargs)
627+
queryset = queryset.filter(**kwargs)
628+
for f in q_filters:
629+
queryset = f.q(queryset)
630+
631+
return queryset
566632

567633
def querystring(self, remove=None):
568634
params = self.params

‎patchwork/tests/unit/views/test_patch.py‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from patchwork.tests.utils import create_project
2626
from patchwork.tests.utils import create_state
2727
from patchwork.tests.utils import create_user
28+
from patchwork.tests.utils import create_label
2829
from patchwork.tests.utils import read_patch
2930

3031

@@ -203,6 +204,136 @@ def test_utf8_handling(self):
203204
self.assertEqual(response.status_code, 200)
204205

205206

207+
class PatchListLabelFilteringTest(TestCase):
208+
def setUp(self):
209+
self.project = create_project()
210+
self.label1 = create_label(project=self.project)
211+
self.label2 = create_label(project=self.project)
212+
self.label_RFC = create_label(project=self.project, name='RFC')
213+
self.label_RFC_global = create_label(project=None, name='RFC')
214+
self.global_label = create_label(project=None)
215+
216+
self.person = create_person(name='test', email='test@example.com')
217+
218+
self.p_no_labels = self.create_patch([])
219+
self.p_label1 = self.create_patch([self.label1])
220+
self.p_label1_2 = self.create_patch([self.label1, self.label2])
221+
self.p_label1_global = self.create_patch(
222+
[self.label1, self.global_label]
223+
)
224+
self.p_label_rfc = self.create_patch([self.label_RFC])
225+
self.p_label_rfc_global = self.create_patch([self.label_RFC_global])
226+
227+
self.patches = [
228+
self.p_no_labels,
229+
self.p_label1,
230+
self.p_label1_2,
231+
self.p_label1_global,
232+
self.p_label_rfc,
233+
self.p_label_rfc_global,
234+
]
235+
236+
def _extract_patch_ids(self, response):
237+
"""Extract patch identifiers from patch list HTML."""
238+
id_re = re.compile(r'<tr id="patch-row:(\d+)"')
239+
ids = [
240+
int(m.group(1)) for m in id_re.finditer(response.content.decode())
241+
]
242+
243+
return ids
244+
245+
def create_patch(self, labels):
246+
return create_patch(
247+
submitter=self.person, project=self.project, labels=labels
248+
)
249+
250+
def _extract_patches(self, response):
251+
"""Extract patches from patch list and return Patch models."""
252+
ids = self._extract_patch_ids(response)
253+
if not ids:
254+
return []
255+
return [Patch.objects.get(id=i) for i in ids]
256+
257+
def filter_labels(self, labels, expected):
258+
"""Test if results contain expected patches when filtered by labels."""
259+
url = reverse(
260+
'patch-list', kwargs={'project_id': self.project.linkname}
261+
)
262+
query = '+'.join(labels)
263+
response = self.client.get(url + '?labels=' + query)
264+
265+
patches = self._extract_patches(response)
266+
self.assertEqual(len(patches), len(expected))
267+
for patch in patches:
268+
self.assertIn(patch, expected)
269+
270+
def test_no_labels(self):
271+
"""Test if no filter returns all patches."""
272+
self.filter_labels([], self.patches)
273+
274+
def test_one_label(self):
275+
self.filter_labels(
276+
[self.label1.name],
277+
[
278+
self.p_label1,
279+
self.p_label1_2,
280+
self.p_label1_global,
281+
],
282+
)
283+
284+
def test_two_labels(self):
285+
self.filter_labels(
286+
[self.label1.name, self.label2.name], [self.p_label1_2]
287+
)
288+
289+
def test_global_label(self):
290+
"""Test filtering by global labels."""
291+
self.filter_labels(
292+
[self.label1.name, self.global_label.name], [self.p_label1_global]
293+
)
294+
295+
def test_negative_label(self):
296+
"""Test filtering with a negative term."""
297+
self.filter_labels(
298+
['-' + self.label2.name],
299+
[
300+
self.p_no_labels,
301+
self.p_label1,
302+
self.p_label1_global,
303+
self.p_label_rfc,
304+
self.p_label_rfc_global,
305+
],
306+
)
307+
308+
def test_positive_negative_label(self):
309+
"""Test filtering with positive and negative term."""
310+
self.filter_labels(
311+
['-' + self.label2.name, self.label1.name],
312+
[
313+
self.p_label1,
314+
self.p_label1_global,
315+
],
316+
)
317+
318+
def test_mutually_exclusive_labels(self):
319+
"""Test filtering by same label as negative and positive filter."""
320+
self.filter_labels(['-' + self.label1.name, self.label1.name], [])
321+
322+
def test_label_local_global(self):
323+
"""Test filtering for patches with different labels, one local and
324+
one global (without a project) that have the same name.
325+
"""
326+
self.filter_labels(
327+
['RFC'], [self.p_label_rfc, self.p_label_rfc_global]
328+
)
329+
330+
def test_label_case_insensitive(self):
331+
"""Test if labels filtering is case-insensitive."""
332+
self.filter_labels(
333+
['rfc'], [self.p_label_rfc, self.p_label_rfc_global]
334+
)
335+
336+
206337
class PatchViewTest(TestCase):
207338
def test_redirect(self):
208339
patch = create_patch()

‎patchwork/tests/utils.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,20 @@ def create_patch(**kwargs):
192192
}
193193
values.update(kwargs)
194194

195+
labels = None
196+
if 'labels' in values:
197+
labels = values['labels']
198+
del values['labels']
199+
195200
patch = Patch.objects.create(**values)
196201

197202
if series:
198203
number = number or series.patches.count() + 1
199204
series.add_patch(patch, number)
200205

206+
if labels is not None:
207+
patch.labels.set(labels)
208+
201209
return patch
202210

203211

0 commit comments

Comments
 (0)