]> git.ipfire.org Git - thirdparty/patchwork.git/commitdiff
REST: Add '/bundle' endpoint
authorStephen Finucane <stephen@that.guru>
Tue, 21 Feb 2017 16:22:41 +0000 (11:22 -0500)
committerStephen Finucane <stephen@that.guru>
Mon, 20 Mar 2017 19:15:09 +0000 (19:15 +0000)
I had initially resisted adding a '/bundle' endpoint to the API as I
wanted to kill this feature in favour of series. However, series are not
a like-for-like replacement for bundles. Among other things, series do
not provide the composability of bundles: bundles can be manually
created, meaning you can use bundles to group not only multiple patches
but also multiple series (or at least the patches in those series).

Seeing as we're not getting rid of this feature, we should expose it via
the API. Bundles are little unusual, in that they can be public or
private, thus, we should only show bundles that are public or belonging
to the currently authenticated user, if any. For now, this is a
read-only endpoint. We may well allow creation of bundles via the API
once we figure out how to do this cleanly.

Signed-off-by: Stephen Finucane <stephen@that.guru>
Reviewed-by: Andy Doan <andy.doan@linaro.org>
patchwork/api/bundle.py [new file with mode: 0644]
patchwork/api/filters.py
patchwork/api/index.py
patchwork/tests/test_rest_api.py
patchwork/urls.py

diff --git a/patchwork/api/bundle.py b/patchwork/api/bundle.py
new file mode 100644 (file)
index 0000000..5fa79b8
--- /dev/null
@@ -0,0 +1,80 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2017 Stephen Finucane <stephen@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 django.db.models import Q
+from rest_framework.generics import ListAPIView
+from rest_framework.generics import RetrieveAPIView
+from rest_framework.serializers import HyperlinkedModelSerializer
+from rest_framework.serializers import SerializerMethodField
+
+from patchwork.api.base import PatchworkPermission
+from patchwork.api.filters import BundleFilter
+from patchwork.models import Bundle
+
+
+class BundleSerializer(HyperlinkedModelSerializer):
+
+    mbox = SerializerMethodField()
+
+    def get_mbox(self, instance):
+        request = self.context.get('request')
+        return request.build_absolute_uri(instance.get_mbox_url())
+
+    class Meta:
+        model = Bundle
+        fields = ('id', 'url', 'project', 'name', 'owner', 'patches',
+                  'public', 'mbox')
+        read_only_fields = ('owner', 'patches', 'mbox')
+        extra_kwargs = {
+            'url': {'view_name': 'api-bundle-detail'},
+            'project': {'view_name': 'api-project-detail'},
+            'owner': {'view_name': 'api-user-detail'},
+            'patches': {'view_name': 'api-patch-detail'},
+        }
+
+
+class BundleMixin(object):
+
+    permission_classes = (PatchworkPermission,)
+    serializer_class = BundleSerializer
+
+    def get_queryset(self):
+        if not self.request.user.is_anonymous():
+            bundle_filter = Q(owner=self.request.user) | Q(public=True)
+        else:
+            bundle_filter = Q(public=True)
+
+        return Bundle.objects\
+            .filter(bundle_filter)\
+            .prefetch_related('patches',)\
+            .select_related('owner', 'project')
+
+
+class BundleList(BundleMixin, ListAPIView):
+    """List bundles."""
+
+    filter_class = BundleFilter
+    search_fields = ('name',)
+    ordering_fields = ('id', 'name', 'owner')
+
+
+class BundleDetail(BundleMixin, RetrieveAPIView):
+    """Show a bundle."""
+
+    pass
index 0f2e6e956f61b008c3b09d77c12cd9d2acfc06cb..eff7ceb9fe548e5caacd457552017a2ae38f9f57 100644 (file)
@@ -20,6 +20,7 @@
 from django_filters import FilterSet
 from django_filters import IsoDateTimeFilter
 
+from patchwork.models import Bundle
 from patchwork.models import Check
 from patchwork.models import CoverLetter
 from patchwork.models import Event
@@ -68,3 +69,10 @@ class EventFilter(FilterSet):
     class Meta:
         model = Event
         fields = ('project', 'series', 'patch', 'cover')
+
+
+class BundleFilter(FilterSet):
+
+    class Meta:
+        model = Bundle
+        fields = ('project', 'owner', 'public')
index 210c32e60e2fd9f16f0e2764446782b6ea32d5c6..513e8b60da1a0214d2b00600f9f84f76bfb1bf6b 100644 (file)
@@ -34,4 +34,5 @@ class IndexView(APIView):
             'covers': request.build_absolute_uri(reverse('api-cover-list')),
             'series': request.build_absolute_uri(reverse('api-series-list')),
             'events': request.build_absolute_uri(reverse('api-event-list')),
+            'bundles': request.build_absolute_uri(reverse('api-bundle-list')),
         })
index b6e61440baece97c2fb219592fc5730ed1b090f3..3a84f0f8222ba501f6adfe387abc2f94b705e3b8 100644 (file)
@@ -26,6 +26,7 @@ from django.core.urlresolvers import reverse
 from patchwork.models import Check
 from patchwork.models import Patch
 from patchwork.models import Project
+from patchwork.tests.utils import create_bundle
 from patchwork.tests.utils import create_check
 from patchwork.tests.utils import create_cover
 from patchwork.tests.utils import create_maintainer
@@ -667,3 +668,79 @@ class TestCheckAPI(APITestCase):
 
         resp = self.client.delete(self.api_url(check))
         self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
+
+
+@unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API')
+class TestBundleAPI(APITestCase):
+    fixtures = ['default_tags']
+
+    @staticmethod
+    def api_url(item=None):
+        if item is None:
+            return reverse('api-bundle-list')
+        return reverse('api-bundle-detail', args=[item])
+
+    def assertSerialized(self, bundle_obj, bundle_json):
+        self.assertEqual(bundle_obj.id, bundle_json['id'])
+        self.assertEqual(bundle_obj.name, bundle_json['name'])
+        self.assertEqual(bundle_obj.public, bundle_json['public'])
+        self.assertIn(bundle_obj.get_mbox_url(), bundle_json['mbox'])
+        self.assertEqual(bundle_obj.patches.count(),
+                         len(bundle_json['patches']))
+        self.assertIn(TestUserAPI.api_url(bundle_obj.owner.id),
+                      bundle_json['owner'])
+        self.assertIn(TestProjectAPI.api_url(bundle_obj.project.id),
+                      bundle_json['project'])
+
+    def test_list(self):
+        """Validate we can list bundles."""
+        resp = self.client.get(self.api_url())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(0, len(resp.data))
+
+        user = create_user()
+        bundle_public = create_bundle(public=True, owner=user)
+        bundle_private = create_bundle(public=False, owner=user)
+
+        # anonymous users
+        # should only see the public bundle
+        resp = self.client.get(self.api_url())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(1, len(resp.data))
+        bundle_rsp = resp.data[0]
+        self.assertSerialized(bundle_public, bundle_rsp)
+
+        # authenticated user
+        # should see the public and private bundle
+        self.client.force_authenticate(user=user)
+        resp = self.client.get(self.api_url())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(2, len(resp.data))
+        for bundle_rsp, bundle_obj in zip(
+                resp.data, [bundle_public, bundle_private]):
+            self.assertSerialized(bundle_obj, bundle_rsp)
+
+    def test_detail(self):
+        """Validate we can get a specific bundle."""
+        user = create_user()
+        bundle = create_bundle(public=True)
+
+        resp = self.client.get(self.api_url(bundle.id))
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertSerialized(bundle, resp.data)
+
+    def test_create_update_delete(self):
+        """Ensure creates, updates and deletes aren't allowed"""
+        user = create_maintainer()
+        user.is_superuser = True
+        user.save()
+        self.client.force_authenticate(user=user)
+
+        resp = self.client.post(self.api_url(), {'email': 'foo@f.com'})
+        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
+
+        resp = self.client.patch(self.api_url(user.id), {'email': 'foo@f.com'})
+        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
+
+        resp = self.client.delete(self.api_url(1))
+        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
index 09b8b31e0883333c4ed6521b046922416424f5c8..57d5cd781eb2ad6d5f5151c5603038db62bab833 100644 (file)
@@ -158,6 +158,7 @@ if settings.ENABLE_REST_API:
         raise RuntimeError(
             'djangorestframework must be installed to enable the REST API.')
 
+    from patchwork.api import bundle as api_bundle_views
     from patchwork.api import check as api_check_views
     from patchwork.api import cover as api_cover_views
     from patchwork.api import event as api_event_views
@@ -208,6 +209,12 @@ if settings.ENABLE_REST_API:
         url(r'^series/(?P<pk>[^/]+)/$',
             api_series_views.SeriesDetail.as_view(),
             name='api-series-detail'),
+        url(r'^bundles/$',
+            api_bundle_views.BundleList.as_view(),
+            name='api-bundle-list'),
+        url(r'^bundles/(?P<pk>[^/]+)/$',
+            api_bundle_views.BundleDetail.as_view(),
+            name='api-bundle-detail'),
         url(r'^projects/$',
             api_project_views.ProjectList.as_view(),
             name='api-project-list'),