[PATCH v3 15/16] REST: Add '/series' endpoint

Stephen Finucane stephen at that.guru
Sat Nov 26 05:18:34 AEDT 2016


Adopt a hybrid approach, by adding an additional series endpoint to the
existing patch endpoint:

    /series/${series_id}/
    /patches/${patch_id}/

This is based on the approach described here:

    http://softwareengineering.stackexchange.com/a/275007/106804

This is necessary due to the N:N mapping of series and patches: it's
possible for a patch to belong to many series, and a series usually
contains many patches. This means it is not possible to rely on the
patch endpoint alone.

It is also necessary to add a cover letter endpoint, such that the
series body can include this.

Signed-off-by: Stephen Finucane <stephen at that.guru>
---
v3:
- Override 'get_queryset' for '/cover' endpoint, resolving an issue
  under Python 3.4
- Don't use defer for '/cover' endpoint when using Django 1.6 due to an
  apparent bug
- Correct 'url' field in '/series' representation (Russell Currey)
---
 patchwork/api/cover.py           |  89 ++++++++++++++++++++++++
 patchwork/api/patch.py           |   9 ++-
 patchwork/api/series.py          |  60 +++++++++++++++++
 patchwork/tests/test_rest_api.py | 141 +++++++++++++++++++++++++++++++++++++++
 patchwork/urls.py                |  14 ++++
 5 files changed, 310 insertions(+), 3 deletions(-)
 create mode 100644 patchwork/api/cover.py
 create mode 100644 patchwork/api/series.py

diff --git a/patchwork/api/cover.py b/patchwork/api/cover.py
new file mode 100644
index 0000000..b440d51
--- /dev/null
+++ b/patchwork/api/cover.py
@@ -0,0 +1,89 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2016 Stephen Finucane <stephen at that.guru>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+import email.parser
+
+import django
+from rest_framework.generics import ListAPIView
+from rest_framework.generics import RetrieveAPIView
+from rest_framework.serializers import HyperlinkedModelSerializer
+from rest_framework.serializers import HyperlinkedRelatedField
+from rest_framework.serializers import SerializerMethodField
+
+from patchwork.models import CoverLetter
+
+
+class CoverLetterListSerializer(HyperlinkedModelSerializer):
+    series = HyperlinkedRelatedField(
+        many=True,
+        read_only=True,
+        view_name='api-series-detail')
+
+    class Meta:
+        model = CoverLetter
+        fields = ('id', 'url', 'project', 'msgid', 'date', 'name', 'submitter',
+                  'series')
+        read_only_fields = fields
+        extra_kwargs = {
+            'url': {'view_name': 'api-cover-detail'},
+            'project': {'view_name': 'api-project-detail'},
+            'submitter': {'view_name': 'api-person-detail'},
+        }
+
+
+class CoverLetterDetailSerializer(CoverLetterListSerializer):
+    headers = SerializerMethodField()
+
+    def get_headers(self, instance):
+        if instance.headers:
+            return email.parser.Parser().parsestr(instance.headers, True)
+
+    class Meta:
+        model = CoverLetter
+        fields = CoverLetterListSerializer.Meta.fields + ('headers', 'content')
+        read_only_fields = CoverLetterListSerializer.Meta.read_only_fields + (
+            'headers', 'content')
+        extra_kwargs = CoverLetterListSerializer.Meta.extra_kwargs
+
+
+class CoverLetterList(ListAPIView):
+    """List cover letters."""
+
+    serializer_class = CoverLetterListSerializer
+
+    def get_queryset(self):
+        qs = CoverLetter.objects.all().prefetch_related('series')\
+            .select_related('submitter')
+
+        # FIXME(stephenfin): This causes issues with Django 1.6 for whatever
+        # reason. Suffer the performance hit on those versions.
+        if django.VERSION >= (1, 7):
+            qs.defer('content', 'headers')
+
+        return qs
+
+
+class CoverLetterDetail(RetrieveAPIView):
+    """Show a cover letter."""
+
+    serializer_class = CoverLetterDetailSerializer
+
+    def get_queryset(self):
+        return CoverLetter.objects.all().prefetch_related('series')\
+            .select_related('submitter')
diff --git a/patchwork/api/patch.py b/patchwork/api/patch.py
index bff1c6a..e78f683 100644
--- a/patchwork/api/patch.py
+++ b/patchwork/api/patch.py
@@ -81,7 +81,8 @@ class PatchListSerializer(HyperlinkedModelSerializer):
         model = Patch
         fields = ('id', 'url', 'project', 'msgid', 'date', 'name',
                   'commit_ref', 'pull_url', 'state', 'archived', 'hash',
-                  'submitter', 'delegate', 'mbox', 'check', 'checks', 'tags')
+                  'submitter', 'delegate', 'mbox', 'series', 'check', 'checks',
+                  'tags')
         read_only_fields = ('project', 'msgid', 'date', 'name', 'hash',
                             'submitter', 'mbox', 'mbox', 'series', 'check',
                             'checks', 'tags')
@@ -90,6 +91,8 @@ class PatchListSerializer(HyperlinkedModelSerializer):
             'project': {'view_name': 'api-project-detail'},
             'submitter': {'view_name': 'api-person-detail'},
             'delegate': {'view_name': 'api-user-detail'},
+            'series': {'view_name': 'api-series-detail',
+                       'lookup_url_kwarg': 'pk'},
         }
 
 
@@ -117,7 +120,7 @@ class PatchList(ListAPIView):
 
     def get_queryset(self):
         return Patch.objects.all().with_tag_counts()\
-            .prefetch_related('check_set')\
+            .prefetch_related('series', 'check_set')\
             .select_related('state', 'submitter', 'delegate')\
             .defer('content', 'diff', 'headers')
 
@@ -130,5 +133,5 @@ class PatchDetail(RetrieveUpdateAPIView):
 
     def get_queryset(self):
         return Patch.objects.all().with_tag_counts()\
-            .prefetch_related('check_set')\
+            .prefetch_related('series', 'check_set')\
             .select_related('state', 'submitter', 'delegate')
diff --git a/patchwork/api/series.py b/patchwork/api/series.py
new file mode 100644
index 0000000..728f256
--- /dev/null
+++ b/patchwork/api/series.py
@@ -0,0 +1,60 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2016 Stephen Finucane <stephen at that.guru>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+from rest_framework.generics import ListAPIView
+from rest_framework.generics import RetrieveAPIView
+from rest_framework.serializers import HyperlinkedModelSerializer
+
+from patchwork.api.base import PatchworkPermission
+from patchwork.models import Series
+
+
+class SeriesSerializer(HyperlinkedModelSerializer):
+
+    class Meta:
+        model = Series
+        fields = ('id', 'url', 'name', 'date', 'submitter', 'version', 'total',
+                  'received_total', 'received_all', 'cover_letter', 'patches')
+        read_only_fields = ('date', 'submitter', 'total', 'received_total',
+                            'received_all', 'cover_letter', 'patches')
+        extra_kwargs = {
+            'url': {'view_name': 'api-series-detail'},
+            'submitter': {'view_name': 'api-person-detail'},
+            'cover_letter': {'view_name': 'api-cover-detail'},
+            'patches': {'view_name': 'api-patch-detail'},
+        }
+
+
+class SeriesMixin(object):
+
+    queryset = Series.objects.all()
+    permission_classes = (PatchworkPermission,)
+    serializer_class = SeriesSerializer
+
+
+class SeriesList(SeriesMixin, ListAPIView):
+    """List series."""
+
+    pass
+
+
+class SeriesDetail(SeriesMixin, RetrieveAPIView):
+    """Show a series."""
+
+    pass
diff --git a/patchwork/tests/test_rest_api.py b/patchwork/tests/test_rest_api.py
index ddc787f..88b7163 100644
--- a/patchwork/tests/test_rest_api.py
+++ b/patchwork/tests/test_rest_api.py
@@ -27,11 +27,13 @@ from patchwork.models import Check
 from patchwork.models import Patch
 from patchwork.models import Project
 from patchwork.tests.utils import create_check
+from patchwork.tests.utils import create_cover
 from patchwork.tests.utils import create_maintainer
 from patchwork.tests.utils import create_patch
 from patchwork.tests.utils import create_person
 from patchwork.tests.utils import create_project
 from patchwork.tests.utils import create_state
+from patchwork.tests.utils import create_series
 from patchwork.tests.utils import create_user
 
 if settings.ENABLE_REST_API:
@@ -415,6 +417,145 @@ class TestPatchAPI(APITestCase):
 
 
 @unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API')
+class TestCoverLetterAPI(APITestCase):
+    fixtures = ['default_tags']
+
+    @staticmethod
+    def api_url(item=None):
+        if item is None:
+            return reverse('api-cover-list')
+        return reverse('api-cover-detail', args=[item])
+
+    def assertSerialized(self, cover_obj, cover_json):
+        self.assertEqual(cover_obj.id, cover_json['id'])
+        self.assertEqual(cover_obj.name, cover_json['name'])
+        self.assertIn(TestPersonAPI.api_url(cover_obj.submitter.id),
+                      cover_json['submitter'])
+
+    def test_list(self):
+        """Validate we can list cover letters."""
+        resp = self.client.get(self.api_url())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(0, len(resp.data))
+
+        cover_obj = create_cover()
+
+        # anonymous user
+        resp = self.client.get(self.api_url())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(1, len(resp.data))
+        self.assertSerialized(cover_obj, resp.data[0])
+
+        # authenticated user
+        user = create_user()
+        self.client.force_authenticate(user=user)
+        resp = self.client.get(self.api_url())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(1, len(resp.data))
+        self.assertSerialized(cover_obj, resp.data[0])
+
+    def test_detail(self):
+        """Validate we can get a specific cover letter."""
+        cover_obj = create_cover()
+
+        resp = self.client.get(self.api_url(cover_obj.id))
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertSerialized(cover_obj, resp.data)
+
+    def test_create_update_delete(self):
+        user = create_maintainer()
+        user.is_superuser = True
+        user.save()
+        self.client.force_authenticate(user=user)
+
+        resp = self.client.post(self.api_url(), {'name': 'test cover'})
+        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
+
+        resp = self.client.patch(self.api_url(), {'name': 'test cover'})
+        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
+
+        resp = self.client.delete(self.api_url())
+        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
+
+
+ at unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API')
+class TestSeriesAPI(APITestCase):
+    fixtures = ['default_tags']
+
+    def api_url(self, item=None):
+        if item is None:
+            return reverse('api-series-list')
+        return reverse('api-series-detail', args=[item])
+
+    def assertSerialized(self, series_obj, series_json):
+        self.assertEqual(series_obj.id, series_json['id'])
+        self.assertEqual(series_obj.name, series_json['name'])
+        self.assertIn(TestPersonAPI.api_url(series_obj.submitter.id),
+                      series_json['submitter'])
+        self.assertEqual(series_obj.patches.count(),
+                         len(series_json['patches']))
+        if series_obj.cover_letter:
+            self.assertIn(
+                TestCoverLetterAPI.api_url(series_obj.cover_letter.id),
+                series_json['cover_letter'])
+
+    def test_list(self):
+        """Validate we can list series."""
+        resp = self.client.get(self.api_url())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(0, len(resp.data))
+
+        series = create_series()
+
+        # anonymous user
+        resp = self.client.get(self.api_url())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(1, len(resp.data))
+        self.assertSerialized(series, resp.data[0])
+
+        # authenticated user
+        user = create_user()
+        self.client.force_authenticate(user=user)
+        resp = self.client.get(self.api_url())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(1, len(resp.data))
+        self.assertSerialized(series, resp.data[0])
+
+    def test_detail(self):
+        """Validate we can get a specific series."""
+        series = create_series()
+
+        resp = self.client.get(self.api_url(series.id))
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertSerialized(series, resp.data)
+
+        patch = create_patch()
+        series.add_patch(patch, 1)
+        resp = self.client.get(self.api_url(series.id))
+        self.assertSerialized(series, resp.data)
+
+        cover_letter = create_cover()
+        series.add_cover_letter(cover_letter)
+        resp = self.client.get(self.api_url(series.id))
+        self.assertSerialized(series, resp.data)
+
+    def test_create_update_delete(self):
+        user = create_maintainer()
+        user.is_superuser = True
+        user.save()
+        self.client.force_authenticate(user=user)
+
+        resp = self.client.post(self.api_url(), {'name': 'test series'})
+        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
+
+        resp = self.client.patch(self.api_url(), {'name': 'test series'})
+        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
+
+        resp = self.client.delete(self.api_url())
+        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
+
+
+ at unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API')
 class TestCheckAPI(APITestCase):
     fixtures = ['default_tags']
 
diff --git a/patchwork/urls.py b/patchwork/urls.py
index 56db24c..68aefc2 100644
--- a/patchwork/urls.py
+++ b/patchwork/urls.py
@@ -154,9 +154,11 @@ if settings.ENABLE_REST_API:
 
     from patchwork.api import check as api_check_views
     from patchwork.api import index as api_index_views
+    from patchwork.api import cover as api_cover_views
     from patchwork.api import patch as api_patch_views
     from patchwork.api import person as api_person_views
     from patchwork.api import project as api_project_views
+    from patchwork.api import series as api_series_views
     from patchwork.api import user as api_user_views
 
     api_patterns = [
@@ -175,6 +177,12 @@ if settings.ENABLE_REST_API:
         url(r'^people/(?P<pk>[^/]+)/$',
             api_person_views.PersonDetail.as_view(),
             name='api-person-detail'),
+        url(r'^covers/$',
+            api_cover_views.CoverLetterList.as_view(),
+            name='api-cover-list'),
+        url(r'^covers/(?P<pk>[^/]+)/$',
+            api_cover_views.CoverLetterDetail.as_view(),
+            name='api-cover-detail'),
         url(r'^patches/$',
             api_patch_views.PatchList.as_view(),
             name='api-patch-list'),
@@ -187,6 +195,12 @@ if settings.ENABLE_REST_API:
         url(r'^patches/(?P<patch_id>[^/]+)/checks/(?P<check_id>[^/]+)/$',
             api_check_views.CheckDetail.as_view(),
             name='api-check-detail'),
+        url(r'^series/$',
+            api_series_views.SeriesList.as_view(),
+            name='api-series-list'),
+        url(r'^series/(?P<pk>[^/]+)/$',
+            api_series_views.SeriesDetail.as_view(),
+            name='api-series-detail'),
         url(r'^projects/$',
             api_project_views.ProjectList.as_view(),
             name='api-project-list'),
-- 
2.7.4



More information about the Patchwork mailing list