[PATCH v2 12/13] REST: Add '/series' endpoint

Stephen Finucane stephen at that.guru
Sun Nov 20 03:51:27 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>
Cc: Andy Doan <andy.doan at linaro.org>
---
 patchwork/api/cover.py           |  77 +++++++++++++++++++++++
 patchwork/api/patch.py           |   9 ++-
 patchwork/api/series.py          |  60 ++++++++++++++++++
 patchwork/tests/test_rest_api.py | 133 +++++++++++++++++++++++++++++++++++++++
 patchwork/urls.py                |  14 +++++
 5 files changed, 290 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..2674be3
--- /dev/null
+++ b/patchwork/api/cover.py
@@ -0,0 +1,77 @@
+# 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
+
+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."""
+
+    queryset = CoverLetter.objects.all().prefetch_related(
+        'series').select_related('submitter').defer('content', 'headers')
+    serializer_class = CoverLetterListSerializer
+
+
+class CoverLetterDetail(RetrieveAPIView):
+    """Show a cover letter."""
+
+    queryset = CoverLetter.objects.all().prefetch_related(
+        'series').select_related('submitter')
+    serializer_class = CoverLetterDetailSerializer
diff --git a/patchwork/api/patch.py b/patchwork/api/patch.py
index e8b6c1c..8d308e8 100644
--- a/patchwork/api/patch.py
+++ b/patchwork/api/patch.py
@@ -78,7 +78,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')
@@ -87,6 +88,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'},
         }
 
 
@@ -110,7 +113,7 @@ class PatchList(ListAPIView):
     """List patches."""
 
     queryset = Patch.objects.all().with_tag_counts().prefetch_related(
-        'check_set').select_related(
+        'series', 'check_set').select_related(
         'state', 'submitter', 'delegate').defer(
         'content', 'diff', 'headers')
     permission_classes = (PatchworkPermission,)
@@ -121,7 +124,7 @@ class PatchDetail(RetrieveUpdateAPIView):
     """Show a patch."""
 
     queryset = Patch.objects.all().with_tag_counts().prefetch_related(
-        'check_set').select_related(
+        'series', 'check_set').select_related(
         'state', 'submitter', 'delegate')
     permission_classes = (PatchworkPermission,)
     serializer_class = PatchDetailSerializer
diff --git a/patchwork/api/series.py b/patchwork/api/series.py
new file mode 100644
index 0000000..fead4ca
--- /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 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-project-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 4675191..6dc8113 100644
--- a/patchwork/tests/test_rest_api.py
+++ b/patchwork/tests/test_rest_api.py
@@ -26,11 +26,13 @@ from django.core.urlresolvers import reverse
 from patchwork.models import Check
 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:
@@ -369,6 +371,137 @@ 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 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))
+        cover_rsp = resp.data[0]
+        self.assertEqual(cover_obj.name, cover_rsp['name'])
+
+        # 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))
+        cover_rsp = resp.data[0]
+        self.assertEqual(cover_obj.name, cover_rsp['name'])
+
+    def test_detail(self):
+        """Validate we can get a specific cover letter."""
+        cover = create_cover()
+
+        resp = self.client.get(self.api_url(cover.id))
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(cover.name, resp.data['name'])
+        self.assertIn(TestPersonAPI.api_url(cover.submitter.id),
+                      resp.data['submitter'])
+
+    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 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_obj = 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))
+        series_rsp = resp.data[0]
+        self.assertEqual(series_obj.name, series_rsp['name'])
+
+        # 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))
+        series_rsp = resp.data[0]
+        self.assertEqual(series_obj.name, series_rsp['name'])
+
+    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.assertEqual(series.name, resp.data['name'])
+        self.assertIn(TestPersonAPI.api_url(series.submitter.id),
+                      resp.data['submitter'])
+
+        patch = create_patch()
+        series.add_patch(patch, 1)
+        resp = self.client.get(self.api_url(series.id))
+        self.assertIn(TestPatchAPI.api_url(patch.id),
+                      resp.data['patches'][0])
+
+        cover_letter = create_cover()
+        series.add_cover_letter(cover_letter)
+        resp = self.client.get(self.api_url(series.id))
+        self.assertIn(TestCoverLetterAPI.api_url(cover_letter.id),
+                      resp.data['cover_letter'])
+
+    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 7c29319..1bf874c 100644
--- a/patchwork/urls.py
+++ b/patchwork/urls.py
@@ -149,9 +149,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 = [
@@ -170,6 +172,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'),
@@ -182,6 +190,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