[v2,2/4] tagging: add tags and related filters to REST API

Message ID 20180917170512.24405-1-vkabatov@redhat.com
State New
Headers show
Series
  • [v2,1/4] Rework tagging infrastructure
Related show

Commit Message

Veronika Kabatova Sept. 17, 2018, 5:05 p.m.
From: Veronika Kabatova <vkabatov@redhat.com>

Signed-off-by: Veronika Kabatova <vkabatov@redhat.com>
---
 patchwork/api/comment.py                      | 12 +++++-
 patchwork/api/cover.py                        | 14 ++++++-
 patchwork/api/filters.py                      | 42 ++++++++++++++++++-
 patchwork/api/patch.py                        | 13 +++---
 patchwork/tests/api/test_patch.py             |  3 +-
 .../tagging-rework-9907e9dc3f835566.yaml      | 11 +++++
 6 files changed, 85 insertions(+), 10 deletions(-)

Patch

diff --git a/patchwork/api/comment.py b/patchwork/api/comment.py
index 5a5adb1d..a328e2a8 100644
--- a/patchwork/api/comment.py
+++ b/patchwork/api/comment.py
@@ -26,6 +26,7 @@  from patchwork.api.base import BaseHyperlinkedModelSerializer
 from patchwork.api.base import PatchworkPermission
 from patchwork.api.embedded import PersonSerializer
 from patchwork.models import Comment
+from patchwork.models import SubmissionTag
 
 
 class CommentListSerializer(BaseHyperlinkedModelSerializer):
@@ -34,6 +35,7 @@  class CommentListSerializer(BaseHyperlinkedModelSerializer):
     subject = SerializerMethodField()
     headers = SerializerMethodField()
     submitter = PersonSerializer(read_only=True)
+    tags = SerializerMethodField()
 
     def get_web_url(self, instance):
         request = self.context.get('request')
@@ -43,6 +45,13 @@  class CommentListSerializer(BaseHyperlinkedModelSerializer):
         return email.parser.Parser().parsestr(comment.headers,
                                               True).get('Subject', '')
 
+    def get_tags(self, instance):
+        tags = {}
+        for tag_object in instance.all_tags:
+            tags[tag_object.name] = instance.all_tags[tag_object]
+
+        return tags
+
     def get_headers(self, comment):
         headers = {}
 
@@ -60,10 +69,11 @@  class CommentListSerializer(BaseHyperlinkedModelSerializer):
     class Meta:
         model = Comment
         fields = ('id', 'web_url', 'msgid', 'date', 'subject', 'submitter',
-                  'content', 'headers')
+                  'content', 'headers', 'tags')
         read_only_fields = fields
         versioned_fields = {
             '1.1': ('web_url', ),
+            '1.2': ('tags', ),
         }
 
 
diff --git a/patchwork/api/cover.py b/patchwork/api/cover.py
index 3a9fc003..191b8418 100644
--- a/patchwork/api/cover.py
+++ b/patchwork/api/cover.py
@@ -30,6 +30,7 @@  from patchwork.api.embedded import PersonSerializer
 from patchwork.api.embedded import ProjectSerializer
 from patchwork.api.embedded import SeriesSerializer
 from patchwork.models import CoverLetter
+from patchwork.models import SubmissionTag
 
 
 class CoverLetterListSerializer(BaseHyperlinkedModelSerializer):
@@ -40,6 +41,7 @@  class CoverLetterListSerializer(BaseHyperlinkedModelSerializer):
     mbox = SerializerMethodField()
     series = SeriesSerializer(read_only=True)
     comments = SerializerMethodField()
+    tags = SerializerMethodField()
 
     def get_web_url(self, instance):
         request = self.context.get('request')
@@ -53,6 +55,13 @@  class CoverLetterListSerializer(BaseHyperlinkedModelSerializer):
         return self.context.get('request').build_absolute_uri(
             reverse('api-cover-comment-list', kwargs={'pk': cover.id}))
 
+    def get_tags(self, instance):
+        tags = {}
+        for tag_object in instance.all_tags:
+            tags[tag_object.name] = instance.all_tags[tag_object]
+
+        return tags
+
     def to_representation(self, instance):
         # NOTE(stephenfin): This is here to ensure our API looks the same even
         # after we changed the series-patch relationship from M:N to 1:N. It
@@ -65,10 +74,11 @@  class CoverLetterListSerializer(BaseHyperlinkedModelSerializer):
     class Meta:
         model = CoverLetter
         fields = ('id', 'url', 'web_url', 'project', 'msgid', 'date', 'name',
-                  'submitter', 'mbox', 'series', 'comments')
+                  'submitter', 'mbox', 'series', 'comments', 'tags')
         read_only_fields = fields
         versioned_fields = {
             '1.1': ('web_url', 'mbox', 'comments'),
+            '1.2': ('tags', ),
         }
         extra_kwargs = {
             'url': {'view_name': 'api-cover-detail'},
@@ -113,6 +123,7 @@  class CoverLetterList(ListAPIView):
 
     def get_queryset(self):
         return CoverLetter.objects.all()\
+            .prefetch_related('tags')\
             .select_related('project', 'submitter', 'series')\
             .defer('content', 'headers')
 
@@ -124,4 +135,5 @@  class CoverLetterDetail(RetrieveAPIView):
 
     def get_queryset(self):
         return CoverLetter.objects.all()\
+            .prefetch_related('tags')\
             .select_related('project', 'submitter', 'series')
diff --git a/patchwork/api/filters.py b/patchwork/api/filters.py
index ddf527fd..416136e4 100644
--- a/patchwork/api/filters.py
+++ b/patchwork/api/filters.py
@@ -21,6 +21,7 @@  from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
 from django.db.models import Q
 from django_filters.rest_framework import FilterSet
+from django.filters import Filter
 from django_filters import IsoDateTimeFilter
 from django_filters import ModelMultipleChoiceFilter
 from django.forms import ModelMultipleChoiceField as BaseMultipleChoiceField
@@ -36,6 +37,7 @@  from patchwork.models import Person
 from patchwork.models import Project
 from patchwork.models import Series
 from patchwork.models import State
+from patchwork.models import SubmissionTag
 
 
 # custom fields, filters
@@ -137,6 +139,40 @@  class StateFilter(ModelMultipleChoiceFilter):
     field_class = StateChoiceField
 
 
+class TagFilter(Filter):
+
+    def filter(self, qs, query):
+        submissions_and_series = []
+
+        for tag_filter in query:
+            try:
+                tag_name, tag_value = tag_filter.split(':', 1)
+            except ValueError:
+                raise ValidationError(
+                    'Query in format `tag=<name>:<value>` expected! <name> or '
+                    '<value> can be missing or wildcard (*) if all tags with '
+                    'given attribute are expected.'
+                )
+            # Map the globbing or missing wildcard to regex syntax
+            if tag_name.strip() in ['', '*']:
+                tag_name = '.*'
+            if tag_value.strip() in ['', '*']:
+                tag_value = '.*'
+
+            submissions_and_series.extend(SubmissionTag.objects.filter(
+                tag__name__regex=tag_name,
+                value__regex=tag_value
+            ).values_list('submission__id', 'series'))
+
+        submission_list = []
+        series_list = []
+        for submission, series in submissions_and_series:
+            submission_list.append(submission)
+            series_list.append(series)
+
+        return qs.filter(Q(id__in=submission_list) | Q(series__in=series_list))
+
+
 class UserChoiceField(ModelMultipleChoiceField):
 
     alternate_lookup = 'username__iexact'
@@ -174,10 +210,11 @@  class CoverLetterFilterSet(TimestampMixin, FilterSet):
     series = BaseFilter(queryset=Project.objects.all(),
                         widget=MultipleHiddenInput)
     submitter = PersonFilter(queryset=Person.objects.all())
+    tag = TagFilter(widget=MultipleHiddenInput)
 
     class Meta:
         model = CoverLetter
-        fields = ('project', 'series', 'submitter')
+        fields = ('project', 'series', 'submitter', 'tag')
 
 
 class PatchFilterSet(TimestampMixin, FilterSet):
@@ -190,11 +227,12 @@  class PatchFilterSet(TimestampMixin, FilterSet):
     submitter = PersonFilter(queryset=Person.objects.all())
     delegate = UserFilter(queryset=User.objects.all())
     state = StateFilter(queryset=State.objects.all())
+    tag = TagFilter(widget=MultipleHiddenInput)
 
     class Meta:
         model = Patch
         fields = ('project', 'series', 'submitter', 'delegate',
-                  'state', 'archived')
+                  'state', 'archived', 'tag')
 
 
 class CheckFilterSet(TimestampMixin, FilterSet):
diff --git a/patchwork/api/patch.py b/patchwork/api/patch.py
index 549ec4fa..12421952 100644
--- a/patchwork/api/patch.py
+++ b/patchwork/api/patch.py
@@ -35,6 +35,7 @@  from patchwork.api.embedded import SeriesSerializer
 from patchwork.api.embedded import UserSerializer
 from patchwork.models import Patch
 from patchwork.models import State
+from patchwork.models import SubmissionTag
 from patchwork.parser import clean_subject
 
 
@@ -109,9 +110,11 @@  class PatchListSerializer(BaseHyperlinkedModelSerializer):
             reverse('api-check-list', kwargs={'patch_id': instance.id}))
 
     def get_tags(self, instance):
-        # TODO(stephenfin): Make tags performant, possibly by reworking the
-        # model
-        return {}
+        tags = {}
+        for tag_object in instance.all_tags:
+            tags[tag_object.name] = instance.all_tags[tag_object]
+
+        return tags
 
     def to_representation(self, instance):
         # NOTE(stephenfin): This is here to ensure our API looks the same even
@@ -183,7 +186,7 @@  class PatchList(ListAPIView):
 
     def get_queryset(self):
         return Patch.objects.all()\
-            .prefetch_related('check_set')\
+            .prefetch_related('check_set', 'tags')\
             .select_related('project', 'state', 'submitter', 'delegate',
                             'series')\
             .defer('content', 'diff', 'headers')
@@ -197,6 +200,6 @@  class PatchDetail(RetrieveUpdateAPIView):
 
     def get_queryset(self):
         return Patch.objects.all()\
-            .prefetch_related('check_set')\
+            .prefetch_related('check_set', 'tags')\
             .select_related('project', 'state', 'submitter', 'delegate',
                             'series')
diff --git a/patchwork/tests/api/test_patch.py b/patchwork/tests/api/test_patch.py
index 104f7c8a..a8498c10 100644
--- a/patchwork/tests/api/test_patch.py
+++ b/patchwork/tests/api/test_patch.py
@@ -166,7 +166,8 @@  class TestPatchAPI(APITestCase):
 
         self.assertEqual(patch.content, resp.data['content'])
         self.assertEqual(patch.diff, resp.data['diff'])
-        self.assertEqual(0, len(resp.data['tags']))
+        self.assertEqual(1, len(resp.data['tags']['Reviewed-by']))
+        self.assertEqual(0, len(resp.data['tags']['Acked-by']))
 
     def test_detail_version_1_0(self):
         patch = create_patch()
diff --git a/releasenotes/notes/tagging-rework-9907e9dc3f835566.yaml b/releasenotes/notes/tagging-rework-9907e9dc3f835566.yaml
index 8a525532..fdfd39f0 100644
--- a/releasenotes/notes/tagging-rework-9907e9dc3f835566.yaml
+++ b/releasenotes/notes/tagging-rework-9907e9dc3f835566.yaml
@@ -6,6 +6,17 @@  features:
     accidentally sent the Acked-by email twice, since only a single same pair  
     tagname-value can be assigned to a patch. Tags from cover letters are now  
     counted towards each patch in the series.
+api:
+  - |
+    The ``tags`` field of the ``/patches`` is now populated and an equivalent
+    field is added for the cover letters and comments. Tags are listed as
+    key-value pairs, making it easier to find where a specific tag originated
+    from.
+  - |
+    Tag filtering on patches and cover letters using ``?tag=<name>:<value>`` is
+    now supported. <name> or <value> can also be missing or wildcard ``*`` in
+    case all tags with given attribute are wanted. For example,
+    ``?tag=Acked-by:*`` returns all patches / cover letters which were acked.
 upgrade:                                                                       
   - |                                                                          
     The ``retag`` command (``python manage.py retag``) needs to be ran after