[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