v2: Merge commits introducing basic objects v3: Introduce the SeriesListMixin class v4: Add the expand get parameter v5: Introduce /api/1.0/ metadata entry point
Signed-off-by: Damien Lespiau <[email protected]> --- docs/api.rst | 363 +++++++++++++++++++++++++++++++++++++++++++++ docs/conf.py | 2 +- docs/index.rst | 1 + docs/requirements-base.txt | 3 + docs/requirements-dev.txt | 1 + patchwork/models.py | 5 +- patchwork/serializers.py | 133 +++++++++++++++++ patchwork/settings/base.py | 7 + patchwork/urls.py | 31 ++++ patchwork/views/api.py | 127 ++++++++++++++++ 10 files changed, 671 insertions(+), 2 deletions(-) create mode 100644 docs/api.rst create mode 100644 patchwork/serializers.py create mode 100644 patchwork/views/api.py diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..7979ac4 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,363 @@ +APIs +=========== + +REST API +-------- + +API metadata +~~~~~~~~~~~~ + +.. http:get:: /api/1.0/ + + Metadata about the API itself. + + .. sourcecode:: http + + GET /api/1.0/ HTTP/1.1 + Accept: application/json + + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "revision": 0 + } + + :>json int revision: API revision. This can be used to ensure the server + supports a feature introduced from a specific revision. + + +Projects +~~~~~~~~ + +A project is merely one of the projects defined for this patchwork instance. + +.. http:get:: /api/1.0/projects/ + + List of all projects. + + .. sourcecode:: http + + GET /api/1.0/projects/ HTTP/1.1 + Accept: application/json + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + Vary: Accept + Allow: GET, HEAD, OPTIONS + + { + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 2, + "name": "beignet", + "linkname": "beignet", + "listemail": "[email protected]", + "web_url": "http://www.freedesktop.org/wiki/Software/Beignet/", + "scm_url": "git://anongit.freedesktop.org/git/beignet", + "webscm_url": "http://cgit.freedesktop.org/beignet/" + }, + { + "id": 1, + "name": "Cairo", + "linkname": "cairo", + "listemail": "[email protected]", + "web_url": "http://www.cairographics.org/", + "scm_url": "git://anongit.freedesktop.org/git/cairo", + "webscm_url": "http://cgit.freedesktop.org/cairo/" + } + ] + } + +.. http:get:: /api/1.0/projects/(string: linkname)/ +.. http:get:: /api/1.0/projects/(int: project_id)/ + + .. sourcecode:: http + + GET /api/1.0/projects/intel-gfx/ HTTP/1.1 + Accept: application/json + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + Vary: Accept + Allow: GET, HEAD, OPTIONS + + { + "id": 1, + "name": "intel-gfx", + "linkname": "intel-gfx", + "listemail": "[email protected]", + "web_url": "", + "scm_url": "", + "webscm_url": "" + } + +Series +~~~~~~ + +A series object represents a lists of patches sent to the mailing-list through +``git-send-email``. It also includes all subsequent patches that are sent to +address review comments, both single patch and full new series. + +A series has then ``n`` revisions, ``n`` going from ``1`` to ``version``. + +.. http:get:: /api/1.0/projects/(string: linkname)/series/ +.. http:get:: /api/1.0/projects/(int: project_id)/series/ + + List of all Series belonging to a specific project. The project can be + specified using either its ``linkname`` or ``id``. + + .. sourcecode:: http + + GET /api/1.0/projects/intel-gfx/series/ HTTP/1.1 + Accept: application/json + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + Vary: Accept + Allow: GET, HEAD, OPTIONS + + { + "count": 59, + "next": "http://patchwork.freedesktop.org/api/1.0/projects/intel-gfx/series/?page=2", + "previous": null, + "results": [ + { + "id": 3, + "project": 1, + "name": "drm/i915: Unwind partial VMA rebinding after failure in set-cache-level", + "n_patches": 1, + "submitter": 77, + "submitted": "2015-10-09T11:51:38", + "last_updated": "2015-10-09T11:51:59.013", + "version": 1, + "reviewer": null + }, + { + "id": 5, + "project": 1, + "name": "RFC drm/i915: Stop the machine whilst capturing the GPU crash dump", + "n_patches": 1, + "submitter": 77, + "submitted": "2015-10-09T12:21:45", + "last_updated": "2015-10-09T12:21:58.657", + "version": 1, + "reviewer": null, + } + ] + } + +.. http:get:: /api/1.0/series/ + + List of all Series known to patchwork. + + .. sourcecode:: http + + GET /api/1.0/series/ HTTP/1.1 + Accept: application/json + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 344, + "next": "http://127.0.0.1:8000/api/1.0/series/?page=2", + "previous": null, + "results": [ + { + "id": 10, + "project": 1, + "name": "intel: New libdrm interface to create unbound wc user mappings for objects", + "n_patches": 1, + "submitter": 10, + "submitted": "2015-01-02T11:06:40", + "last_updated": "2015-10-09T07:55:18.608", + "version": 1, + "reviewer": null + }, + { + "id": 1, + "project": 1, + "name": "PMIC based Panel and Backlight Control", + "n_patches": 4, + "submitter": 1, + "submitted": "2014-12-26T10:23:26", + "last_updated": "2015-10-09T07:55:01.558", + "version": 1, + "reviewer": null, + }, + ] + } + +.. http:get:: /api/1.0/series/(int: series_id)/ + + A series (`series_id`). A Series object contains metadata about the series. + + .. sourcecode:: http + + GET /api/1.0/series/47/ HTTP/1.1 + Accept: application/json + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + Vary: Accept + Allow: GET, PUT, PATCH, HEAD, OPTIONS + + { + "id": 47, + "name": "Series without cover letter", + "n_patches": 2, + "submitter": 21, + "submitted": "2015-01-13T09:32:24", + "last_updated": "2015-10-09T07:57:23.541", + "version": 1, + "reviewer": null + } + +.. http:get:: /api/1.0/series/(int: series_id)/revisions/ + + The list of revisions of the series `series_id`. + + .. sourcecode:: http + + GET /api/1.0/series/47/revisions/ HTTP/1.1 + Accept: application/json + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + Vary: Accept + Allow: GET, HEAD, OPTIONS + + [ + { + "version": 1, + "cover_letter": null, + "patches": [ + 120, + 121 + ] + } + ] + +.. http:get:: /api/1.0/series/(int: series_id)/revisions/(int: version)/ + + The specific ``version`` of the series `series_id`. + + .. sourcecode:: http + + GET /api/1.0/series/47/revisions/1/ HTTP/1.1 + Accept: application/json + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + Vary: Accept + Allow: GET, HEAD, OPTIONS + + { + "version": 1, + "cover_letter": null, + "patches": [ + 120, + 121 + ] + } + +Patches +~~~~~~~ + +.. http:get:: /api/1.0/patches/ + + List of all patches. + + .. sourcecode:: http + + GET /api/1.0/patches/ HTTP/1.1 + Accept: application/json + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + Vary: Accept + Allow: GET, HEAD, OPTIONS + + { + "count": 1392, + "next": "http://127.0.0.1:8000/api/1.0/patches/?page=2", + "previous": null, + "results": [ + { + "id": 1, + "project": 1, + "name": "[RFC,1/4] drm/i915: Define a common data structure for Panel Info", + "date": "2014-12-26T10:23:27", + "submitter": 1, + "state": 1, + "content": "<diff content>" + }, + { + "id": 4, + "project": 1, + "name": "[RFC,2/4] drm/i915: Add a drm_panel over INTEL_SOC_PMIC", + "date": "2014-12-26T10:23:28", + "submitter": 1, + "state": 1, + "content": "<diff content>" + } + ] + } + +.. http:get:: /api/1.0/patches/(int: patch_id)/ + + A specific patch. + + .. sourcecode:: http + + GET /api/1.0/patches/120/ HTTP/1.1 + Accept: application/json + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + Vary: Accept + Allow: GET, HEAD, OPTIONS + + { + "id": 120, + "name": "[1/2] drm/i915: Balance context pinning on reset cleanup", + "date": "2015-01-13T09:32:24", + "submitter": 21, + "state": 1, + "content": "<diff content>" + } + +API Revisions +~~~~~~~~~~~~~ + +**Revision 0** + +- Initial revision. Basic objects exposed: api root, projects, series, + revisions and patches. diff --git a/docs/conf.py b/docs/conf.py index d45985d..0bdc031 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,7 +29,7 @@ import shlex # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [] +extensions = ['sphinxcontrib.httpdomain'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/index.rst b/docs/index.rst index 7322a9a..5351e30 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,5 +14,6 @@ Contents: intro installation manual + api development diff --git a/docs/requirements-base.txt b/docs/requirements-base.txt index 58b34ec..7314ab3 100644 --- a/docs/requirements-base.txt +++ b/docs/requirements-base.txt @@ -1,2 +1,5 @@ MySQL-python==1.2.5 python-dateutil==1.5 +djangorestframework>=2.4.8,<3.0.0 +drf-nested-routers +enum34 diff --git a/docs/requirements-dev.txt b/docs/requirements-dev.txt index 19a9b91..6021175 100644 --- a/docs/requirements-dev.txt +++ b/docs/requirements-dev.txt @@ -1,3 +1,4 @@ -r requirements-base.txt selenium sphinx +sphinxcontrib-httpdomain diff --git a/patchwork/models.py b/patchwork/models.py index 3ede65c..24fac6c 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -39,12 +39,15 @@ class Person(models.Model): user = models.ForeignKey(User, null = True, blank = True, on_delete = models.SET_NULL) - def __unicode__(self): + def display_name(self): if self.name: return self.name else: return self.email + def __unicode__(self): + return self.display_name() + def link_to_user(self, user): self.name = user.profile.name() self.user = user diff --git a/patchwork/serializers.py b/patchwork/serializers.py new file mode 100644 index 0000000..418a140 --- /dev/null +++ b/patchwork/serializers.py @@ -0,0 +1,133 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2014 Intel 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 django.contrib.auth.models import User +from patchwork.models import Project, Series, SeriesRevision, Patch, Person, \ + State +from rest_framework import serializers +from enum import Enum + +class RelatedMode(Enum): + """Select how to show related fields in the JSON responses.""" + primary_key = 1 + expand = 2 + +class PatchworkModelSerializerOptions(serializers.ModelSerializerOptions): + """Meta class options for PatchworkModelSerializer""" + def __init__(self, meta): + super(PatchworkModelSerializerOptions, self).__init__(meta) + self.expand_serializers = getattr(meta, 'expand_serializers', {}) + +class PatchworkModelSerializer(serializers.ModelSerializer): + """A model serializer with configurable related fields. + + PatchworkModelSerializer can either show related fields as a integer + or expand them to include the related full JSON object. + This behaviour is selectable through the 'related' GET parameter. Adding + 'related=expand' to the GET request will expand related fields. + """ + + _options_class = PatchworkModelSerializerOptions + + def __init__(self, *args, **kwargs): + super(PatchworkModelSerializer, self).__init__(*args, **kwargs) + + self._pw_related = RelatedMode.primary_key + related = self.context['request'].QUERY_PARAMS.get('related') + if not related: + return + + try: + self._pw_related = RelatedMode[related] + except KeyError: + pass + + def _pw_get_nested_field(self, model_field, related_model, to_many): + class NestedModelSerializer(serializers.ModelSerializer): + class Meta: + model = related_model + + if model_field.name in self.opts.expand_serializers: + serializer_class = self.opts.expand_serializers[model_field.name] + return serializer_class(context=self.context, many=to_many) + return NestedModelSerializer(many=to_many) + + def get_related_field(self, model_field, related_model, to_many): + if self._pw_related == RelatedMode.expand: + return self._pw_get_nested_field(model_field, related_model, to_many) + else: + return super(PatchworkModelSerializer, self). \ + get_related_field(model_field, related_model, to_many) + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ('id', 'username', 'first_name', 'last_name', ) + +class PersonSerializer(serializers.ModelSerializer): + name = serializers.CharField(source='display_name', read_only=True) + class Meta: + model = Person + fields = ('id', 'name', ) + +class ProjectSerializer(serializers.ModelSerializer): + class Meta: + model = Project + fields = ('id', 'name', 'linkname', 'listemail', 'web_url', 'scm_url', + 'webscm_url') + +class StateSerializer(serializers.ModelSerializer): + class Meta: + model = State + fields = ('id', 'name') + +class SeriesSerializer(PatchworkModelSerializer): + class Meta: + model = Series + fields = ('id', 'project', 'name', 'n_patches', 'submitter', + 'submitted', 'last_updated', 'version', 'reviewer') + read_only_fields = ('project', 'n_patches', 'submitter', 'submitted', + 'last_updated', 'version') + expand_serializers = { + 'project': ProjectSerializer, + 'submitter': PersonSerializer, + 'reviewer': UserSerializer, + } + +class PatchSerializer(PatchworkModelSerializer): + class Meta: + model = Patch + fields = ('id', 'project', 'name', 'date', 'submitter', 'state', + 'content') + read_only_fields = ('id', 'project', 'name', 'date', 'submitter', + 'content') + expand_serializers = { + 'project': ProjectSerializer, + 'submitter': PersonSerializer, + 'state': StateSerializer, + } + +class RevisionSerializer(PatchworkModelSerializer): + class Meta: + model = SeriesRevision + fields = ('version', 'cover_letter', 'patches') + read_only_fields = ('version', 'cover_letter') + expand_serializers = { + 'patches': PatchSerializer, + } diff --git a/patchwork/settings/base.py b/patchwork/settings/base.py index ab03814..6edf7c6 100644 --- a/patchwork/settings/base.py +++ b/patchwork/settings/base.py @@ -24,6 +24,7 @@ INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.staticfiles', 'patchwork', + 'rest_framework', ] # HTTP @@ -97,6 +98,12 @@ STATICFILES_DIRS = [ os.path.join(ROOT_DIR, 'htdocs'), ] +# +# REST framework + +REST_FRAMEWORK = { +} + # # Patchwork settings diff --git a/patchwork/urls.py b/patchwork/urls.py index bc792d0..3ba734e 100644 --- a/patchwork/urls.py +++ b/patchwork/urls.py @@ -21,12 +21,43 @@ from django.conf.urls import patterns, url, include from django.conf import settings from django.contrib import admin from django.contrib.auth import views as auth_views +from rest_framework_nested import routers +import patchwork.views.api as api + +# API + +# /projects/$project/ +project_router = routers.SimpleRouter() +project_router.register('projects', api.ProjectViewSet) +# /projects/$project/series/ +series_list_router = routers.NestedSimpleRouter(project_router, 'projects', + lookup='project') +series_list_router.register(r'series', api.SeriesListViewSet) +# /series/$id/ +series_router = routers.SimpleRouter() +series_router.register(r'series', api.SeriesViewSet) +# /series/$id/revisions/$rev +revisions_router = routers.NestedSimpleRouter(series_router, 'series', + lookup='series') +revisions_router.register(r'revisions', api.RevisionViewSet) +# /patches/$id/ +patches_router = routers.SimpleRouter() +patches_router.register(r'patches', api.PatchViewSet) admin.autodiscover() urlpatterns = patterns('', url(r'^admin/', include(admin.site.urls)), + # API + (r'^api/1.0/$', api.API.as_view()), + (r'^api/1.0/', include(project_router.urls)), + (r'^api/1.0/', include(series_list_router.urls)), + (r'^api/1.0/', include(series_router.urls)), + (r'^api/1.0/', include(revisions_router.urls)), + (r'^api/1.0/', include(patches_router.urls)), + + # project view: (r'^$', 'patchwork.views.projects'), (r'^project/(?P<project_id>[^/]+)/list/$', 'patchwork.views.patch.list'), (r'^project/(?P<project_id>[^/]+)/$', 'patchwork.views.project.project'), diff --git a/patchwork/views/api.py b/patchwork/views/api.py new file mode 100644 index 0000000..01539b6 --- /dev/null +++ b/patchwork/views/api.py @@ -0,0 +1,127 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2014 Intel 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 patchwork.models import Project, Series, SeriesRevision, Patch +from rest_framework import views, viewsets, mixins, generics, filters, permissions +from rest_framework.decorators import api_view, renderer_classes, \ + permission_classes +from rest_framework.renderers import JSONRenderer +from rest_framework.response import Response +from rest_framework.generics import get_object_or_404 +from patchwork.serializers import ProjectSerializer, SeriesSerializer, \ + RevisionSerializer, PatchSerializer + + +API_REVISION = 0 + +class MaintainerPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + # read only for everyone + if request.method in permissions.SAFE_METHODS: + return True + + # editable for maintainers + user = request.user + if not user.is_authenticated(): + return False + return obj.project.is_editable(user) + +class API(views.APIView): + permission_classes = (permissions.AllowAny,) + + def get(self, request, format=None): + return Response({ 'revision': API_REVISION }) + +class ListMixin(object): + paginate_by = 20 + paginate_by_param = 'perpage' + max_paginate_by = 100 + +class SeriesListMixin(ListMixin): + queryset = Series.objects.all() + serializer_class = SeriesSerializer + filter_backends = (filters.OrderingFilter, ) + ordering_fields = ('name', 'n_patches', 'submitter__name', 'reviewer__name', + 'submitted', 'last_updated') + +def is_integer(s): + try: + int(s) + return True + except ValueError: + return False + +class ProjectViewSet(mixins.ListModelMixin, ListMixin, viewsets.GenericViewSet): + permission_classes = (MaintainerPermission, ) + queryset = Project.objects.all() + serializer_class = ProjectSerializer + + def retrieve(self, request, pk=None): + if is_integer(pk): + queryset = get_object_or_404(Project, pk=pk) + else: + queryset = get_object_or_404(Project, linkname=pk) + serializer = ProjectSerializer(queryset) + return Response(serializer.data) + +class SeriesListViewSet(mixins.ListModelMixin, + SeriesListMixin, + viewsets.GenericViewSet): + permission_classes = (MaintainerPermission, ) + + def get_queryset(self): + + pk = self.kwargs['project_pk'] + if is_integer(pk): + queryset = self.queryset.filter(project__pk=pk) + else: + queryset = self.queryset.filter(project__linkname=pk) + return queryset + +class SeriesViewSet(mixins.ListModelMixin, + mixins.RetrieveModelMixin, + SeriesListMixin, + viewsets.GenericViewSet): + permission_classes = (MaintainerPermission, ) + queryset = Series.objects.all() + +class RevisionViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): + permission_classes = (MaintainerPermission, ) + queryset = SeriesRevision.objects.all() + serializer_class = RevisionSerializer + + def get_queryset(self): + + series_pk = self.kwargs['series_pk'] + return self.queryset.filter(series=series_pk) + + def retrieve(self, request, series_pk=None, pk=None): + rev = get_object_or_404(SeriesRevision, series=series_pk, version=pk) + print(self.get_serializer_context()) + serializer = RevisionSerializer(rev, + context=self.get_serializer_context()) + return Response(serializer.data) + +class PatchViewSet(mixins.ListModelMixin, + mixins.RetrieveModelMixin, + ListMixin, + viewsets.GenericViewSet): + permission_classes = (MaintainerPermission, ) + queryset = Patch.objects.all() + serializer_class = PatchSerializer -- 2.4.3 _______________________________________________ Patchwork mailing list [email protected] https://lists.ozlabs.org/listinfo/patchwork
