diff --git a/docs/api/v3.rst b/docs/api/v3.rst index 8fc12c8a85e..e4788083fc9 100644 --- a/docs/api/v3.rst +++ b/docs/api/v3.rst @@ -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 " 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 ~~~~~~~~ @@ -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**: @@ -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 diff --git a/readthedocs/api/v3/mixins.py b/readthedocs/api/v3/mixins.py index e0d500c4b7b..bd608d1a793 100644 --- a/readthedocs/api/v3/mixins.py +++ b/readthedocs/api/v3/mixins.py @@ -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 @@ -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//`` 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): @@ -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) diff --git a/readthedocs/api/v3/permissions.py b/readthedocs/api/v3/permissions.py index d269d6072ea..dbe090e75c3 100644 --- a/readthedocs/api/v3/permissions.py +++ b/readthedocs/api/v3/permissions.py @@ -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() diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py index f583229c40b..44a90721e33 100644 --- a/readthedocs/api/v3/serializers.py +++ b/readthedocs/api/v3/serializers.py @@ -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 @@ -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() diff --git a/readthedocs/api/v3/tests/mixins.py b/readthedocs/api/v3/tests/mixins.py index 24054bb6d8c..263aaf7bc59 100644 --- a/readthedocs/api/v3/tests/mixins.py +++ b/readthedocs/api/v3/tests/mixins.py @@ -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], diff --git a/readthedocs/api/v3/tests/test_projects.py b/readthedocs/api/v3/tests/test_projects.py index 49e58b043e3..a74aad0ba83 100644 --- a/readthedocs/api/v3/tests/test_projects.py +++ b/readthedocs/api/v3/tests/test_projects.py @@ -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) diff --git a/readthedocs/api/v3/views.py b/readthedocs/api/v3/views.py index 11ef4fe2380..7d863a0b20e 100644 --- a/readthedocs/api/v3/views.py +++ b/readthedocs/api/v3/views.py @@ -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 ( @@ -36,6 +36,7 @@ EnvironmentVariableSerializer, ProjectSerializer, ProjectCreateSerializer, + ProjectUpdateSerializer, RedirectCreateSerializer, RedirectDetailSerializer, VersionSerializer, @@ -73,6 +74,7 @@ class APIv3Settings: class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, FlexFieldsMixin, ProjectImportMixin, CreateModelMixin, + UpdateMixin, UpdateModelMixin, ReadOnlyModelViewSet): # Markdown docstring is automatically rendered by BrowsableAPIRenderer. @@ -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': @@ -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' @@ -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):