Skip to content

Commit 7fcdf5c

Browse files
authored
Merge pull request readthedocs#5952 from readthedocs/humitos/apiv3/project-update-endpoint
APIv3 endpoint: allow to modify a Project once it's imported
2 parents 3492617 + 13c86e5 commit 7fcdf5c

File tree

7 files changed

+202
-21
lines changed

7 files changed

+202
-21
lines changed

docs/api/v3.rst

+31-2
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,35 @@ Project create
227227
:statuscode 400: Some field is invalid
228228

229229

230+
Project update
231+
++++++++++++++
232+
233+
.. http:patch:: /api/v3/projects/(string:project_slug)/
234+
235+
Update an existing project.
236+
237+
**Example request**:
238+
239+
.. sourcecode:: bash
240+
241+
$ curl \
242+
-X PATCH \
243+
-H "Authorization: Token <token>" https://readthedocs.org/api/v3/projects/pip/ \
244+
-H "Content-Type: application/json" \
245+
-d @body.json
246+
247+
The content of ``body.json`` is like,
248+
249+
.. sourcecode:: json
250+
251+
{
252+
"name": "New name for the project",
253+
"default_version": "v0.27.0"
254+
}
255+
256+
:statuscode 204: Updated successfully
257+
258+
230259
Versions
231260
~~~~~~~~
232261

@@ -342,7 +371,7 @@ Version update
342371

343372
.. http:patch:: /api/v3/projects/(string:project_slug)/version/(string:version_slug)/
344373
345-
Edit a version.
374+
Update a version.
346375

347376
**Example request**:
348377

@@ -355,7 +384,7 @@ Version update
355384

356385
:requestheader Authorization: token to authenticate.
357386

358-
:statuscode 204: Edited successfully
387+
:statuscode 204: Updated successfully
359388
:statuscode 400: Some field is invalid
360389
:statuscode 401: Not valid permissions
361390
:statuscode 404: There is no ``Version`` with this slug for this project

readthedocs/api/v3/mixins.py

+21
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from django.shortcuts import get_object_or_404
2+
from rest_framework import status
3+
from rest_framework.response import Response
24

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

2931
def _get_parent_project(self):
3032
slug = self._get_parent_object_lookup(self.PROJECT_LOOKUP_NAMES)
33+
34+
# when hitting ``/projects/<slug>/`` we don't have a "parent" project
35+
# because this endpoint is the base one, so we just get the project from
36+
# ``project_slug`` kwargs
37+
slug = slug or self.kwargs.get('project_slug')
38+
3139
return get_object_or_404(Project, slug=slug)
3240

3341
def _get_parent_version(self):
@@ -92,3 +100,16 @@ def get_queryset(self):
92100

93101
# List view are only allowed if user is owner of parent project
94102
return self.listing_objects(queryset, self.request.user)
103+
104+
105+
class UpdateMixin:
106+
107+
"""Make PUT to return 204 on success like PATCH does."""
108+
109+
def update(self, request, *args, **kwargs):
110+
# NOTE: ``Authorization:`` header is mandatory to use this method from
111+
# Browsable API since SessionAuthentication can't be used because we set
112+
# ``httpOnly`` on our cookies and the ``PUT/PATCH`` method are triggered
113+
# via Javascript
114+
super().update(request, *args, **kwargs)
115+
return Response(status=status.HTTP_204_NO_CONTENT)

readthedocs/api/v3/permissions.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,14 @@ def has_permission(self, request, view):
2222
# hitting ``/projects/``, allowing
2323
return True
2424

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

2835
project = view._get_parent_project()

readthedocs/api/v3/serializers.py

+44-1
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@
44
from django.conf import settings
55
from django.contrib.auth.models import User
66
from django.core.urlresolvers import reverse
7+
from django.utils.translation import ugettext as _
8+
79
from rest_flex_fields import FlexFieldsModelSerializer
810
from rest_flex_fields.serializers import FlexFieldsSerializerMixin
911
from rest_framework import serializers
1012

1113
from readthedocs.builds.models import Build, Version
12-
from readthedocs.projects.constants import LANGUAGES, PROGRAMMING_LANGUAGES, REPO_CHOICES
14+
from readthedocs.projects.constants import (
15+
LANGUAGES,
16+
PROGRAMMING_LANGUAGES,
17+
REPO_CHOICES,
18+
PRIVACY_CHOICES,
19+
PROTECTED,
20+
)
1321
from readthedocs.projects.models import Project, EnvironmentVariable
1422
from readthedocs.redirects.models import Redirect, TYPE_CHOICES as REDIRECT_TYPE_CHOICES
1523

@@ -419,6 +427,41 @@ class Meta:
419427
)
420428

421429

430+
class ProjectUpdateSerializer(FlexFieldsModelSerializer):
431+
432+
"""Serializer used to modify a Project once imported."""
433+
434+
repository = RepositorySerializer(source='*')
435+
homepage = serializers.URLField(source='project_url')
436+
437+
# Exclude ``Protected`` as a possible value for Privacy Level
438+
privacy_level_choices = list(PRIVACY_CHOICES)
439+
privacy_level_choices.remove((PROTECTED, _('Protected')))
440+
privacy_level = serializers.ChoiceField(choices=privacy_level_choices)
441+
442+
class Meta:
443+
model = Project
444+
fields = (
445+
# Settings
446+
'name',
447+
'repository',
448+
'language',
449+
'programming_language',
450+
'homepage',
451+
452+
# Advanced Settings -> General Settings
453+
'default_version',
454+
'default_branch',
455+
'privacy_level',
456+
'analytics_code',
457+
'show_version_warning',
458+
'single_version',
459+
460+
# NOTE: we do not allow to change any setting that can be set via
461+
# the YAML config file.
462+
)
463+
464+
422465
class ProjectSerializer(FlexFieldsModelSerializer):
423466

424467
homepage = serializers.SerializerMethodField()

readthedocs/api/v3/tests/mixins.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def setUp(self):
106106
self.others_token = fixture.get(Token, key='other', user=self.other)
107107
self.others_project = fixture.get(
108108
Project,
109-
slug='others_project',
109+
slug='others-project',
110110
related_projects=[],
111111
main_language_project=None,
112112
users=[self.other],

readthedocs/api/v3/tests/test_projects.py

+89
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,92 @@ def test_import_project_with_extra_fields(self):
181181
self.assertEqual(project.repo, 'https://github.com/rtfd/template')
182182
self.assertNotEqual(project.default_version, 'v1.0')
183183
self.assertIn(self.me, project.users.all())
184+
185+
def test_update_project(self):
186+
data = {
187+
'name': 'Updated name',
188+
'repository': {
189+
'url': 'https://bitbucket.com/rtfd/updated-repository',
190+
'type': 'hg',
191+
},
192+
'language': 'es',
193+
'programming_language': 'js',
194+
'homepage': 'https://updated-homepage.org',
195+
'default_version': 'stable',
196+
'default_branch': 'updated-default-branch',
197+
'privacy_level': 'private',
198+
'analytics_code': 'UA-XXXXXX',
199+
'show_version_warning': False,
200+
'single_version': True,
201+
}
202+
203+
self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
204+
response = self.client.put(
205+
reverse(
206+
'projects-detail',
207+
kwargs={
208+
'project_slug': self.project.slug,
209+
},
210+
),
211+
data,
212+
)
213+
self.assertEqual(response.status_code, 204)
214+
215+
self.project.refresh_from_db()
216+
self.assertEqual(self.project.name, 'Updated name')
217+
self.assertEqual(self.project.slug, 'project')
218+
self.assertEqual(self.project.repo, 'https://bitbucket.com/rtfd/updated-repository')
219+
self.assertEqual(self.project.repo_type, 'hg')
220+
self.assertEqual(self.project.language, 'es')
221+
self.assertEqual(self.project.programming_language, 'js')
222+
self.assertEqual(self.project.project_url, 'https://updated-homepage.org')
223+
self.assertEqual(self.project.default_version, 'stable')
224+
self.assertEqual(self.project.default_branch, 'updated-default-branch')
225+
self.assertEqual(self.project.privacy_level, 'private')
226+
self.assertEqual(self.project.analytics_code, 'UA-XXXXXX')
227+
self.assertEqual(self.project.show_version_warning, False)
228+
self.assertEqual(self.project.single_version, True)
229+
230+
def test_partial_update_project(self):
231+
data = {
232+
'name': 'Updated name',
233+
'repository': {
234+
'url': 'https://github.com/rtfd/updated-repository',
235+
},
236+
'default_branch': 'updated-default-branch',
237+
}
238+
239+
self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
240+
response = self.client.patch(
241+
reverse(
242+
'projects-detail',
243+
kwargs={
244+
'project_slug': self.project.slug,
245+
},
246+
),
247+
data,
248+
)
249+
self.assertEqual(response.status_code, 204)
250+
251+
self.project.refresh_from_db()
252+
self.assertEqual(self.project.name, 'Updated name')
253+
self.assertEqual(self.project.slug, 'project')
254+
self.assertEqual(self.project.repo, 'https://github.com/rtfd/updated-repository')
255+
self.assertNotEqual(self.project.default_version, 'updated-default-branch')
256+
257+
def test_partial_update_others_project(self):
258+
data = {
259+
'name': 'Updated name',
260+
}
261+
262+
self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
263+
response = self.client.patch(
264+
reverse(
265+
'projects-detail',
266+
kwargs={
267+
'project_slug': self.others_project.slug,
268+
},
269+
),
270+
data,
271+
)
272+
self.assertEqual(response.status_code, 403)

readthedocs/api/v3/views.py

+8-16
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828

2929
from .filters import BuildFilter, ProjectFilter, VersionFilter
30-
from .mixins import ProjectQuerySetMixin
30+
from .mixins import ProjectQuerySetMixin, UpdateMixin
3131
from .permissions import PublicDetailPrivateListing, IsProjectAdmin
3232
from .renderers import AlphabeticalSortedJSONRenderer
3333
from .serializers import (
@@ -36,6 +36,7 @@
3636
EnvironmentVariableSerializer,
3737
ProjectSerializer,
3838
ProjectCreateSerializer,
39+
ProjectUpdateSerializer,
3940
RedirectCreateSerializer,
4041
RedirectDetailSerializer,
4142
VersionSerializer,
@@ -73,6 +74,7 @@ class APIv3Settings:
7374

7475
class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
7576
FlexFieldsMixin, ProjectImportMixin, CreateModelMixin,
77+
UpdateMixin, UpdateModelMixin,
7678
ReadOnlyModelViewSet):
7779

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

156+
if self.action in ('update', 'partial_update'):
157+
return ProjectUpdateSerializer
158+
154159
def get_queryset(self):
155160
# Allow hitting ``/api/v3/projects/`` to list their own projects
156161
if self.basename == 'projects' and self.action == 'list':
@@ -271,7 +276,8 @@ class TranslationRelationshipViewSet(APIv3Settings, NestedViewSetMixin,
271276
# of ``ProjectQuerySetMixin`` to make calling ``super().get_queryset()`` work
272277
# properly and filter nested dependencies
273278
class VersionsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
274-
FlexFieldsMixin, UpdateModelMixin, ReadOnlyModelViewSet):
279+
FlexFieldsMixin, UpdateMixin,
280+
UpdateModelMixin, ReadOnlyModelViewSet):
275281

276282
model = Version
277283
lookup_field = 'slug'
@@ -298,20 +304,6 @@ def get_serializer_class(self):
298304
return VersionSerializer
299305
return VersionUpdateSerializer
300306

301-
def update(self, request, *args, **kwargs):
302-
"""
303-
Make PUT/PATCH behaves in the same way.
304-
305-
Force to return 204 is the update was good.
306-
"""
307-
308-
# NOTE: ``Authorization:`` header is mandatory to use this method from
309-
# Browsable API since SessionAuthentication can't be used because we set
310-
# ``httpOnly`` on our cookies and the ``PUT/PATCH`` method are triggered
311-
# via Javascript
312-
super().update(request, *args, **kwargs)
313-
return Response(status=status.HTTP_204_NO_CONTENT)
314-
315307

316308
class BuildsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
317309
FlexFieldsMixin, ReadOnlyModelViewSet):

0 commit comments

Comments
 (0)