Skip to content

Commit

Permalink
Merge pull request #5952 from readthedocs/humitos/apiv3/project-updat…
Browse files Browse the repository at this point in the history
…e-endpoint

APIv3 endpoint: allow to modify a Project once it's imported
  • Loading branch information
humitos authored Oct 8, 2019
2 parents 3492617 + 13c86e5 commit 7fcdf5c
Show file tree
Hide file tree
Showing 7 changed files with 202 additions and 21 deletions.
33 changes: 31 additions & 2 deletions docs/api/v3.rst
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,35 @@ Project create
:statuscode 400: Some field is invalid


Project update
++++++++++++++

.. http:patch:: /api/v3/projects/(string:project_slug)/
Update an existing project.

**Example request**:

.. sourcecode:: bash

$ curl \
-X PATCH \
-H "Authorization: Token <token>" https://readthedocs.org/api/v3/projects/pip/ \
-H "Content-Type: application/json" \
-d @body.json

The content of ``body.json`` is like,

.. sourcecode:: json

{
"name": "New name for the project",
"default_version": "v0.27.0"
}

:statuscode 204: Updated successfully


Versions
~~~~~~~~

Expand Down Expand Up @@ -342,7 +371,7 @@ Version update

.. http:patch:: /api/v3/projects/(string:project_slug)/version/(string:version_slug)/
Edit a version.
Update a version.

**Example request**:

Expand All @@ -355,7 +384,7 @@ Version update

:requestheader Authorization: token to authenticate.

:statuscode 204: Edited successfully
:statuscode 204: Updated successfully
:statuscode 400: Some field is invalid
:statuscode 401: Not valid permissions
:statuscode 404: There is no ``Version`` with this slug for this project
Expand Down
21 changes: 21 additions & 0 deletions readthedocs/api/v3/mixins.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.response import Response

from readthedocs.builds.models import Version
from readthedocs.projects.models import Project
Expand Down Expand Up @@ -28,6 +30,12 @@ def _get_parent_object_lookup(self, lookup_names):

def _get_parent_project(self):
slug = self._get_parent_object_lookup(self.PROJECT_LOOKUP_NAMES)

# when hitting ``/projects/<slug>/`` we don't have a "parent" project
# because this endpoint is the base one, so we just get the project from
# ``project_slug`` kwargs
slug = slug or self.kwargs.get('project_slug')

return get_object_or_404(Project, slug=slug)

def _get_parent_version(self):
Expand Down Expand Up @@ -92,3 +100,16 @@ def get_queryset(self):

# List view are only allowed if user is owner of parent project
return self.listing_objects(queryset, self.request.user)


class UpdateMixin:

"""Make PUT to return 204 on success like PATCH does."""

def update(self, request, *args, **kwargs):
# NOTE: ``Authorization:`` header is mandatory to use this method from
# Browsable API since SessionAuthentication can't be used because we set
# ``httpOnly`` on our cookies and the ``PUT/PATCH`` method are triggered
# via Javascript
super().update(request, *args, **kwargs)
return Response(status=status.HTTP_204_NO_CONTENT)
9 changes: 8 additions & 1 deletion readthedocs/api/v3/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ def has_permission(self, request, view):
# hitting ``/projects/``, allowing
return True

if view.detail:
# NOTE: ``superproject`` is an action name, defined by the class
# method under ``ProjectViewSet``. We should apply the same
# permissions restrictions than for a detail action (since it only
# returns one superproject if exists). ``list`` and ``retrieve`` are
# DRF standard action names (same as ``update`` or ``partial_update``).
if view.detail and view.action in ('list', 'retrieve', 'superproject'):
# detail view is only allowed on list/retrieve actions (not
# ``update`` or ``partial_update``).
return True

project = view._get_parent_project()
Expand Down
45 changes: 44 additions & 1 deletion readthedocs/api/v3/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@
from django.conf import settings
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _

from rest_flex_fields import FlexFieldsModelSerializer
from rest_flex_fields.serializers import FlexFieldsSerializerMixin
from rest_framework import serializers

from readthedocs.builds.models import Build, Version
from readthedocs.projects.constants import LANGUAGES, PROGRAMMING_LANGUAGES, REPO_CHOICES
from readthedocs.projects.constants import (
LANGUAGES,
PROGRAMMING_LANGUAGES,
REPO_CHOICES,
PRIVACY_CHOICES,
PROTECTED,
)
from readthedocs.projects.models import Project, EnvironmentVariable
from readthedocs.redirects.models import Redirect, TYPE_CHOICES as REDIRECT_TYPE_CHOICES

Expand Down Expand Up @@ -419,6 +427,41 @@ class Meta:
)


class ProjectUpdateSerializer(FlexFieldsModelSerializer):

"""Serializer used to modify a Project once imported."""

repository = RepositorySerializer(source='*')
homepage = serializers.URLField(source='project_url')

# Exclude ``Protected`` as a possible value for Privacy Level
privacy_level_choices = list(PRIVACY_CHOICES)
privacy_level_choices.remove((PROTECTED, _('Protected')))
privacy_level = serializers.ChoiceField(choices=privacy_level_choices)

class Meta:
model = Project
fields = (
# Settings
'name',
'repository',
'language',
'programming_language',
'homepage',

# Advanced Settings -> General Settings
'default_version',
'default_branch',
'privacy_level',
'analytics_code',
'show_version_warning',
'single_version',

# NOTE: we do not allow to change any setting that can be set via
# the YAML config file.
)


class ProjectSerializer(FlexFieldsModelSerializer):

homepage = serializers.SerializerMethodField()
Expand Down
2 changes: 1 addition & 1 deletion readthedocs/api/v3/tests/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def setUp(self):
self.others_token = fixture.get(Token, key='other', user=self.other)
self.others_project = fixture.get(
Project,
slug='others_project',
slug='others-project',
related_projects=[],
main_language_project=None,
users=[self.other],
Expand Down
89 changes: 89 additions & 0 deletions readthedocs/api/v3/tests/test_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,92 @@ def test_import_project_with_extra_fields(self):
self.assertEqual(project.repo, 'https://github.com/rtfd/template')
self.assertNotEqual(project.default_version, 'v1.0')
self.assertIn(self.me, project.users.all())

def test_update_project(self):
data = {
'name': 'Updated name',
'repository': {
'url': 'https://bitbucket.com/rtfd/updated-repository',
'type': 'hg',
},
'language': 'es',
'programming_language': 'js',
'homepage': 'https://updated-homepage.org',
'default_version': 'stable',
'default_branch': 'updated-default-branch',
'privacy_level': 'private',
'analytics_code': 'UA-XXXXXX',
'show_version_warning': False,
'single_version': True,
}

self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
response = self.client.put(
reverse(
'projects-detail',
kwargs={
'project_slug': self.project.slug,
},
),
data,
)
self.assertEqual(response.status_code, 204)

self.project.refresh_from_db()
self.assertEqual(self.project.name, 'Updated name')
self.assertEqual(self.project.slug, 'project')
self.assertEqual(self.project.repo, 'https://bitbucket.com/rtfd/updated-repository')
self.assertEqual(self.project.repo_type, 'hg')
self.assertEqual(self.project.language, 'es')
self.assertEqual(self.project.programming_language, 'js')
self.assertEqual(self.project.project_url, 'https://updated-homepage.org')
self.assertEqual(self.project.default_version, 'stable')
self.assertEqual(self.project.default_branch, 'updated-default-branch')
self.assertEqual(self.project.privacy_level, 'private')
self.assertEqual(self.project.analytics_code, 'UA-XXXXXX')
self.assertEqual(self.project.show_version_warning, False)
self.assertEqual(self.project.single_version, True)

def test_partial_update_project(self):
data = {
'name': 'Updated name',
'repository': {
'url': 'https://github.com/rtfd/updated-repository',
},
'default_branch': 'updated-default-branch',
}

self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
response = self.client.patch(
reverse(
'projects-detail',
kwargs={
'project_slug': self.project.slug,
},
),
data,
)
self.assertEqual(response.status_code, 204)

self.project.refresh_from_db()
self.assertEqual(self.project.name, 'Updated name')
self.assertEqual(self.project.slug, 'project')
self.assertEqual(self.project.repo, 'https://github.com/rtfd/updated-repository')
self.assertNotEqual(self.project.default_version, 'updated-default-branch')

def test_partial_update_others_project(self):
data = {
'name': 'Updated name',
}

self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
response = self.client.patch(
reverse(
'projects-detail',
kwargs={
'project_slug': self.others_project.slug,
},
),
data,
)
self.assertEqual(response.status_code, 403)
24 changes: 8 additions & 16 deletions readthedocs/api/v3/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@


from .filters import BuildFilter, ProjectFilter, VersionFilter
from .mixins import ProjectQuerySetMixin
from .mixins import ProjectQuerySetMixin, UpdateMixin
from .permissions import PublicDetailPrivateListing, IsProjectAdmin
from .renderers import AlphabeticalSortedJSONRenderer
from .serializers import (
Expand All @@ -36,6 +36,7 @@
EnvironmentVariableSerializer,
ProjectSerializer,
ProjectCreateSerializer,
ProjectUpdateSerializer,
RedirectCreateSerializer,
RedirectDetailSerializer,
VersionSerializer,
Expand Down Expand Up @@ -73,6 +74,7 @@ class APIv3Settings:

class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
FlexFieldsMixin, ProjectImportMixin, CreateModelMixin,
UpdateMixin, UpdateModelMixin,
ReadOnlyModelViewSet):

# Markdown docstring is automatically rendered by BrowsableAPIRenderer.
Expand Down Expand Up @@ -151,6 +153,9 @@ def get_serializer_class(self):
if self.action == 'create':
return ProjectCreateSerializer

if self.action in ('update', 'partial_update'):
return ProjectUpdateSerializer

def get_queryset(self):
# Allow hitting ``/api/v3/projects/`` to list their own projects
if self.basename == 'projects' and self.action == 'list':
Expand Down Expand Up @@ -271,7 +276,8 @@ class TranslationRelationshipViewSet(APIv3Settings, NestedViewSetMixin,
# of ``ProjectQuerySetMixin`` to make calling ``super().get_queryset()`` work
# properly and filter nested dependencies
class VersionsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
FlexFieldsMixin, UpdateModelMixin, ReadOnlyModelViewSet):
FlexFieldsMixin, UpdateMixin,
UpdateModelMixin, ReadOnlyModelViewSet):

model = Version
lookup_field = 'slug'
Expand All @@ -298,20 +304,6 @@ def get_serializer_class(self):
return VersionSerializer
return VersionUpdateSerializer

def update(self, request, *args, **kwargs):
"""
Make PUT/PATCH behaves in the same way.
Force to return 204 is the update was good.
"""

# NOTE: ``Authorization:`` header is mandatory to use this method from
# Browsable API since SessionAuthentication can't be used because we set
# ``httpOnly`` on our cookies and the ``PUT/PATCH`` method are triggered
# via Javascript
super().update(request, *args, **kwargs)
return Response(status=status.HTTP_204_NO_CONTENT)


class BuildsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
FlexFieldsMixin, ReadOnlyModelViewSet):
Expand Down

0 comments on commit 7fcdf5c

Please sign in to comment.