diff options
author | Andy Doan <andy.doan@linaro.org> | 2016-06-16 16:13:18 -0500 |
---|---|---|
committer | Stephen Finucane <stephen.finucane@intel.com> | 2016-06-27 18:19:54 +0100 |
commit | ee15585ed48e67dc584bfbc8f1af12225abdfd44 (patch) | |
tree | 0731016077bd44011e93db4cc2354914f50ef9c9 /patchwork | |
parent | 92b6e6a39595841782b967e34e9b3bdebefbf1ec (diff) | |
download | patchwork-ee15585ed48e67dc584bfbc8f1af12225abdfd44.tar patchwork-ee15585ed48e67dc584bfbc8f1af12225abdfd44.tar.gz |
REST: Add Projects to the API
This exports projects via the REST API.
Security Constraints:
* Anyone (logged in or not) can read all objects.
* No one can create/delete objects.
* Project maintainers are allowed to update (ie "patch"
attributes)
Signed-off-by: Andy Doan <andy.doan@linaro.org>
Signed-off-by: Stephen Finucane <stephen.finucane@intel.com>
Inspired-by: Damien Lespiau <damien.lespiau@intel.com>
Diffstat (limited to 'patchwork')
-rw-r--r-- | patchwork/rest_serializers.py | 35 | ||||
-rw-r--r-- | patchwork/settings/base.py | 4 | ||||
-rw-r--r-- | patchwork/tests/test_rest_api.py | 116 | ||||
-rw-r--r-- | patchwork/urls.py | 3 | ||||
-rw-r--r-- | patchwork/views/rest_api.py | 60 |
5 files changed, 215 insertions, 3 deletions
diff --git a/patchwork/rest_serializers.py b/patchwork/rest_serializers.py new file mode 100644 index 0000000..974d6d3 --- /dev/null +++ b/patchwork/rest_serializers.py @@ -0,0 +1,35 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2016 Linaro Corporation +# +# 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.serializers import HyperlinkedModelSerializer + +from patchwork.models import Project + + +class ProjectSerializer(HyperlinkedModelSerializer): + class Meta: + model = Project + exclude = ('send_notifications', 'use_tags') + + def to_representation(self, instance): + data = super(ProjectSerializer, self).to_representation(instance) + data['link_name'] = data.pop('linkname') + data['list_email'] = data.pop('listemail') + data['list_id'] = data.pop('listid') + return data diff --git a/patchwork/settings/base.py b/patchwork/settings/base.py index 14f3209..735c67a 100644 --- a/patchwork/settings/base.py +++ b/patchwork/settings/base.py @@ -158,6 +158,10 @@ ENABLE_XMLRPC = False # Set to True to enable the Patchwork REST API ENABLE_REST_API = False +REST_RESULTS_PER_PAGE = 30 +REST_FRAMEWORK = { + 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning' +} # Set to True to enable redirections or URLs from previous versions # of patchwork diff --git a/patchwork/tests/test_rest_api.py b/patchwork/tests/test_rest_api.py new file mode 100644 index 0000000..010900a --- /dev/null +++ b/patchwork/tests/test_rest_api.py @@ -0,0 +1,116 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2016 Linaro Corporation +# +# 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 unittest + +from django.conf import settings +from django.core.urlresolvers import reverse + +from rest_framework import status +from rest_framework.test import APITestCase + +from patchwork.models import Project +from patchwork.tests.utils import defaults, create_maintainer, create_user + + +@unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API') +class TestProjectAPI(APITestCase): + fixtures = ['default_states'] + + def setUp(self): + self.project = defaults.project + self.project.save() + + @staticmethod + def api_url(item=None): + if item is None: + return reverse('api_1.0:project-list') + return reverse('api_1.0:project-detail', args=[item]) + + def test_list_simple(self): + """Validate we can list the default test project.""" + resp = self.client.get(self.api_url()) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(1, len(resp.data)) + proj = resp.data[0] + self.assertEqual(self.project.linkname, proj['link_name']) + self.assertEqual(self.project.name, proj['name']) + self.assertEqual(self.project.listid, proj['list_id']) + + def test_detail(self): + """Validate we can get a specific project.""" + resp = self.client.get(self.api_url(self.project.id)) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(self.project.name, resp.data['name']) + + def test_anonymous_create(self): + """Ensure anonymous POST operations are rejected.""" + resp = self.client.post( + self.api_url(), + {'linkname': 'l', 'name': 'n', 'listid': 'l', 'listemail': 'e'}) + self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code) + + def test_anonymous_update(self): + """Ensure anonymous "PATCH" operations are rejected.""" + resp = self.client.patch(self.api_url(self.project.id), + {'linkname': 'foo'}) + self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code) + + def test_anonymous_delete(self): + """Ensure anonymous "DELETE" operations are rejected.""" + resp = self.client.delete(self.api_url(self.project.id)) + self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code) + + def test_create(self): + """Ensure creations are rejected.""" + user = create_maintainer(self.project) + user.is_superuser = True + user.save() + self.client.force_authenticate(user=user) + resp = self.client.post( + self.api_url(), + {'linkname': 'l', 'name': 'n', 'listid': 'l', 'listemail': 'e'}) + self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code) + + def test_update(self): + """Ensure updates can be performed maintainers.""" + # A maintainer can update + user = create_maintainer(self.project) + self.client.force_authenticate(user=user) + resp = self.client.patch(self.api_url(self.project.id), + {'linkname': 'TEST'}) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + + # A normal user can't + user = create_user() + self.client.force_authenticate(user=user) + resp = self.client.patch(self.api_url(self.project.id), + {'linkname': 'TEST'}) + self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code) + + def test_delete(self): + """Ensure deletions are rejected.""" + # Even an admin can't remove a project + user = create_maintainer(self.project) + user.is_superuser = True + user.save() + self.client.force_authenticate(user=user) + resp = self.client.delete(self.api_url(self.project.id)) + self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code) + self.assertEqual(1, Project.objects.all().count()) diff --git a/patchwork/urls.py b/patchwork/urls.py index 2318ab9..44d794e 100644 --- a/patchwork/urls.py +++ b/patchwork/urls.py @@ -149,7 +149,8 @@ if settings.ENABLE_REST_API: 'djangorestframework must be installed to enable the REST API.') import patchwork.views.rest_api urlpatterns += [ - url(r'^api/1.0/', include(patchwork.views.rest_api.router.urls)), + url(r'^api/1.0/', include( + patchwork.views.rest_api.router.urls, namespace='api_1.0')), ] # redirect from old urls diff --git a/patchwork/views/rest_api.py b/patchwork/views/rest_api.py index 5436ed6..8c207ff 100644 --- a/patchwork/views/rest_api.py +++ b/patchwork/views/rest_api.py @@ -17,6 +17,62 @@ # 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 import routers +from django.conf import settings -router = routers.DefaultRouter() +from patchwork.models import Project +from patchwork.rest_serializers import ProjectSerializer + +from rest_framework import permissions +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response +from rest_framework.routers import DefaultRouter +from rest_framework.viewsets import ModelViewSet + + +class LinkHeaderPagination(PageNumberPagination): + """Provide pagination based on rfc5988 (how github does it) + https://tools.ietf.org/html/rfc5988#section-5 + https://developer.github.com/guides/traversing-with-pagination + """ + page_size = settings.REST_RESULTS_PER_PAGE + page_size_query_param = 'per_page' + + def get_paginated_response(self, data): + next_url = self.get_next_link() + previous_url = self.get_previous_link() + + link = '' + if next_url is not None and previous_url is not None: + link = '<{next_url}>; rel="next", <{previous_url}>; rel="prev"' + elif next_url is not None: + link = '<{next_url}>; rel="next"' + elif previous_url is not None: + link = '<{previous_url}>; rel="prev"' + link = link.format(next_url=next_url, previous_url=previous_url) + headers = {'Link': link} if link else {} + return Response(data, headers=headers) + + +class PatchworkPermission(permissions.BasePermission): + """This permission works for Project and Patch model objects""" + def has_permission(self, request, view): + if request.method in ('POST', 'DELETE'): + return False + return super(PatchworkPermission, self).has_permission(request, view) + + def has_object_permission(self, request, view, obj): + # read only for everyone + if request.method in permissions.SAFE_METHODS: + return True + return obj.is_editable(request.user) + + +class ProjectViewSet(ModelViewSet): + permission_classes = (PatchworkPermission, ) + queryset = Project.objects.all() + serializer_class = ProjectSerializer + pagination_class = LinkHeaderPagination + + +router = DefaultRouter() +router.register('projects', ProjectViewSet, 'project') |