Skip to content

Commit 2f23562

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 312838f commit 2f23562

4 files changed

Lines changed: 267 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/models.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from django.urls import reverse
2020
from django.utils.functional import cached_property
2121
from django.utils import timezone as tz_utils
22+
from django.db.models.functions import Lower
23+
from django.db.models import OuterRef, F, Func
2224

2325
from patchwork.fields import HashField, ColorField
2426
from patchwork.hasher import hash_diff
@@ -397,6 +399,50 @@ def with_tag_counts(self, project=None):
397399

398400
return qs
399401

402+
def exclude_labels(self, labels):
403+
if len(labels) > 0:
404+
labels = [label.lower() for label in labels]
405+
label_ids = list(
406+
Label.objects.annotate(lname=Lower('name'))
407+
.filter(lname__in=labels)
408+
.values_list(
409+
'id', flat=True
410+
)
411+
)
412+
labels_sq = (
413+
Patch.labels.through.objects.filter(
414+
patch_id=OuterRef('pk'), label_id__in=label_ids
415+
)
416+
.annotate(label_num=Func(F('label_id'), function='COUNT'))
417+
.values('label_num')
418+
)
419+
self = self.annotate(num_labels=labels_sq).filter(num_labels=0)
420+
421+
return self
422+
423+
def include_labels(self, labels):
424+
if len(labels) > 0:
425+
labels = [label.lower() for label in labels]
426+
label_ids = list(
427+
Label.objects.annotate(lname=Lower('name'))
428+
.filter(lname__in=labels)
429+
.values_list(
430+
'id', flat=True
431+
)
432+
)
433+
labels_sq = (
434+
Patch.labels.through.objects.filter(
435+
patch_id=OuterRef('pk'), label_id__in=label_ids
436+
)
437+
.annotate(label_num=Func(F('label_id'), function='COUNT'))
438+
.values('label_num')
439+
)
440+
self = self.annotate(num_labels=labels_sq).filter(
441+
num_labels__gte=len(labels)
442+
)
443+
444+
return self
445+
400446

401447
class PatchManager(models.Manager):
402448
def get_queryset(self):

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

Lines changed: 144 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,149 @@ 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.global_label = create_label(project=None)
214+
215+
person = create_person(name='test', email='test@example.com')
216+
217+
self.patches = [
218+
create_patch(submitter=person, project=self.project, labels=[]),
219+
create_patch(
220+
submitter=person, project=self.project, labels=[self.label1]
221+
),
222+
create_patch(
223+
submitter=person,
224+
project=self.project,
225+
labels=[self.label1, self.label2],
226+
),
227+
create_patch(
228+
submitter=person,
229+
project=self.project,
230+
labels=[self.label1, self.global_label],
231+
),
232+
create_patch(
233+
submitter=person,
234+
project=self.project,
235+
labels=[self.label_RFC],
236+
),
237+
]
238+
239+
def _extract_patch_ids(self, response):
240+
id_re = re.compile(r'<tr id="patch-row:(\d+)"')
241+
ids = [
242+
int(m.group(1)) for m in id_re.finditer(response.content.decode())
243+
]
244+
245+
return ids
246+
247+
def _extract_patches(self, response):
248+
ids = self._extract_patch_ids(response)
249+
if not ids:
250+
return []
251+
return [Patch.objects.get(id=i) for i in ids]
252+
253+
def test_no_labels(self):
254+
url = reverse(
255+
'patch-list', kwargs={'project_id': self.project.linkname}
256+
)
257+
response = self.client.get(url + '?labels=')
258+
259+
patches = self._extract_patches(response)
260+
self.assertEqual(len(patches), len(self.patches))
261+
262+
def test_one_label(self):
263+
url = reverse(
264+
'patch-list', kwargs={'project_id': self.project.linkname}
265+
)
266+
response = self.client.get(url + '?labels=' + self.label1.name)
267+
268+
patches = self._extract_patches(response)
269+
self.assertEqual(len(patches), 3)
270+
self.assertIn(self.patches[1], patches)
271+
self.assertIn(self.patches[2], patches)
272+
self.assertIn(self.patches[3], patches)
273+
274+
def test_two_labels(self):
275+
url = reverse(
276+
'patch-list', kwargs={'project_id': self.project.linkname}
277+
)
278+
response = self.client.get(
279+
url + '?labels=' + self.label1.name + '+' + self.label2.name
280+
)
281+
282+
patches = self._extract_patches(response)
283+
self.assertEqual(len(patches), 1)
284+
self.assertIn(self.patches[2], patches)
285+
286+
def test_global_label(self):
287+
url = reverse(
288+
'patch-list', kwargs={'project_id': self.project.linkname}
289+
)
290+
response = self.client.get(
291+
url + '?labels=' + self.label1.name + '+' + self.global_label.name
292+
)
293+
294+
patches = self._extract_patches(response)
295+
self.assertEqual(len(patches), 1)
296+
self.assertIn(self.patches[3], patches)
297+
298+
def test_negative_label(self):
299+
url = reverse(
300+
'patch-list', kwargs={'project_id': self.project.linkname}
301+
)
302+
response = self.client.get(url + '?labels=-' + self.label2.name)
303+
304+
patches = self._extract_patches(response)
305+
self.assertEqual(len(patches), 4)
306+
self.assertIn(self.patches[0], patches)
307+
self.assertIn(self.patches[1], patches)
308+
self.assertIn(self.patches[3], patches)
309+
self.assertIn(self.patches[4], patches)
310+
311+
def test_positive_negative_label(self):
312+
url = reverse(
313+
'patch-list', kwargs={'project_id': self.project.linkname}
314+
)
315+
response = self.client.get(
316+
url + '?labels=-' + self.label2.name + '+' + self.label1.name
317+
)
318+
319+
patches = self._extract_patches(response)
320+
self.assertEqual(len(patches), 2)
321+
self.assertIn(self.patches[1], patches)
322+
self.assertIn(self.patches[3], patches)
323+
324+
def test_mutually_exclusive_labels(self):
325+
url = reverse(
326+
'patch-list', kwargs={'project_id': self.project.linkname}
327+
)
328+
response = self.client.get(
329+
url + '?labels=-' + self.label1.name + '+' + self.label1.name
330+
)
331+
332+
patches = self._extract_patches(response)
333+
self.assertEqual(len(patches), 0)
334+
335+
def test_label_case_insensitive(self):
336+
url = reverse(
337+
'patch-list', kwargs={'project_id': self.project.linkname}
338+
)
339+
response = self.client.get(url + '?labels=' + 'RFC')
340+
341+
patches = self._extract_patches(response)
342+
self.assertEqual(len(patches), 1)
343+
344+
response = self.client.get(url + '?labels=' + 'rfc')
345+
346+
patches = self._extract_patches(response)
347+
self.assertEqual(len(patches), 1)
348+
349+
206350
class PatchViewTest(TestCase):
207351
def test_redirect(self):
208352
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)