From: Stephen Finucane Date: Fri, 4 Nov 2016 14:17:46 +0000 (+0000) Subject: REST: Add '/series' endpoint X-Git-Tag: v2.0.0-rc1~115 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=f1d7c8f7dca818ef4df0b22a7f9f879ff59da782;p=thirdparty%2Fpatchwork.git REST: Add '/series' endpoint 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 --- diff --git a/patchwork/api/cover.py b/patchwork/api/cover.py new file mode 100644 index 00000000..b440d511 --- /dev/null +++ b/patchwork/api/cover.py @@ -0,0 +1,89 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2016 Stephen Finucane +# +# 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 bff1c6ab..e78f683d 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 00000000..ade37fb9 --- /dev/null +++ b/patchwork/api/series.py @@ -0,0 +1,63 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2016 Stephen Finucane +# +# 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): + + permission_classes = (PatchworkPermission,) + serializer_class = SeriesSerializer + + def get_queryset(self): + return Series.objects.all().prefetch_related('patches',)\ + .select_related('submitter', 'cover_letter') + + +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 ddc787fa..88b7163b 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: @@ -414,6 +416,145 @@ class TestPatchAPI(APITestCase): self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code) +@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) + + +@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) + + @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 56db24cc..68aefc2d 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[^/]+)/$', 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[^/]+)/$', + 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[^/]+)/checks/(?P[^/]+)/$', 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[^/]+)/$', + api_series_views.SeriesDetail.as_view(), + name='api-series-detail'), url(r'^projects/$', api_project_views.ProjectList.as_view(), name='api-project-list'),