Skip to content

Commit e574168

Browse files
authored
APIv3 "Import Project" endpoint (readthedocs#5857)
APIv3 "Import Project" endpoint
2 parents 801f63b + b29b3b5 commit e574168

File tree

8 files changed

+259
-36
lines changed

8 files changed

+259
-36
lines changed

readthedocs/api/v3/permissions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def has_permission(self, request, view):
1616
if is_authenticated:
1717
if view.basename == 'projects' and any([
1818
view.action == 'list',
19+
view.action == 'create', # used to create Form in BrowsableAPIRenderer
1920
view.action is None, # needed for BrowsableAPIRenderer
2021
]):
2122
# hitting ``/projects/``, allowing

readthedocs/api/v3/serializers.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from rest_framework import serializers
1010

1111
from readthedocs.builds.models import Build, Version
12-
from readthedocs.projects.constants import LANGUAGES, PROGRAMMING_LANGUAGES
12+
from readthedocs.projects.constants import LANGUAGES, PROGRAMMING_LANGUAGES, REPO_CHOICES
1313
from readthedocs.projects.models import Project
1414
from readthedocs.redirects.models import Redirect, TYPE_CHOICES as REDIRECT_TYPE_CHOICES
1515

@@ -313,7 +313,10 @@ def get_project_homepage(self, obj):
313313
class RepositorySerializer(serializers.Serializer):
314314

315315
url = serializers.CharField(source='repo')
316-
type = serializers.CharField(source='repo_type')
316+
type = serializers.ChoiceField(
317+
source='repo_type',
318+
choices=REPO_CHOICES,
319+
)
317320

318321

319322
class ProjectLinksSerializer(BaseLinksSerializer):
@@ -386,6 +389,24 @@ def get_translations(self, obj):
386389
return self._absolute_url(path)
387390

388391

392+
class ProjectCreateSerializer(FlexFieldsModelSerializer):
393+
394+
"""Serializer used to Import a Project."""
395+
396+
repository = RepositorySerializer(source='*')
397+
homepage = serializers.URLField(source='project_url', required=False)
398+
399+
class Meta:
400+
model = Project
401+
fields = (
402+
'name',
403+
'language',
404+
'programming_language',
405+
'repository',
406+
'homepage',
407+
)
408+
409+
389410
class ProjectSerializer(FlexFieldsModelSerializer):
390411

391412
language = LanguageSerializer()
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"_links": {
3+
"_self": "https://readthedocs.org/api/v3/projects/test-project/",
4+
"builds": "https://readthedocs.org/api/v3/projects/test-project/builds/",
5+
"redirects": "https://readthedocs.org/api/v3/projects/test-project/redirects/",
6+
"subprojects": "https://readthedocs.org/api/v3/projects/test-project/subprojects/",
7+
"superproject": "https://readthedocs.org/api/v3/projects/test-project/superproject/",
8+
"translations": "https://readthedocs.org/api/v3/projects/test-project/translations/",
9+
"versions": "https://readthedocs.org/api/v3/projects/test-project/versions/"
10+
},
11+
"created": "2019-04-29T10:00:00Z",
12+
"default_branch": "master",
13+
"default_version": "latest",
14+
"description": null,
15+
"id": 4,
16+
"language": {
17+
"code": "en",
18+
"name": "English"
19+
},
20+
"modified": "2019-04-29T12:00:00Z",
21+
"name": "Test Project",
22+
"privacy_level": {
23+
"code": "public",
24+
"name": "Public"
25+
},
26+
"programming_language": {
27+
"code": "py",
28+
"name": "Python"
29+
},
30+
"repository": {
31+
"type": "git",
32+
"url": "https://github.com/rtfd/template"
33+
},
34+
"slug": "test-project",
35+
"subproject_of": null,
36+
"tags": [],
37+
"translation_of": null,
38+
"urls": {
39+
"documentation": "http://readthedocs.org/docs/test-project/en/latest/",
40+
"project_homepage": "http://template.readthedocs.io/"
41+
},
42+
"users": [
43+
{
44+
"created": "2019-04-29T10:00:00Z",
45+
"username": "testuser"
46+
}
47+
]
48+
}

readthedocs/api/v3/tests/test_projects.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ def test_projects_versions_detail_unique(self):
384384
)
385385
self.assertEqual(response.status_code, 200)
386386

387+
387388
def test_unauthed_projects_redirects_list(self):
388389
response = self.client.get(
389390
reverse(
@@ -529,3 +530,66 @@ def test_projects_redirects_detail_delete(self):
529530
)
530531
self.assertEqual(response.status_code, 204)
531532
self.assertEqual(self.project.redirects.count(), 0)
533+
534+
535+
def test_import_project(self):
536+
data = {
537+
'name': 'Test Project',
538+
'repository': {
539+
'url': 'https://github.com/rtfd/template',
540+
'type': 'git',
541+
},
542+
'homepage': 'http://template.readthedocs.io/',
543+
'programming_language': 'py',
544+
}
545+
546+
self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
547+
response = self.client.post(reverse('projects-list'), data)
548+
self.assertEqual(response.status_code, 201)
549+
550+
query = Project.objects.filter(slug='test-project')
551+
self.assertTrue(query.exists())
552+
553+
project = query.first()
554+
self.assertEqual(project.name, 'Test Project')
555+
self.assertEqual(project.slug, 'test-project')
556+
self.assertEqual(project.repo, 'https://github.com/rtfd/template')
557+
self.assertEqual(project.language, 'en')
558+
self.assertEqual(project.programming_language, 'py')
559+
self.assertEqual(project.privacy_level, 'public')
560+
self.assertEqual(project.project_url, 'http://template.readthedocs.io/')
561+
self.assertIn(self.me, project.users.all())
562+
self.assertEqual(project.builds.count(), 1)
563+
564+
response_json = response.json()
565+
response_json['created'] = '2019-04-29T10:00:00Z'
566+
response_json['modified'] = '2019-04-29T12:00:00Z'
567+
568+
self.assertDictEqual(
569+
response_json,
570+
self._get_response_dict('projects-list_POST'),
571+
)
572+
573+
def test_import_project_with_extra_fields(self):
574+
data = {
575+
'name': 'Test Project',
576+
'repository': {
577+
'url': 'https://github.com/rtfd/template',
578+
'type': 'git',
579+
},
580+
'default_version': 'v1.0', # ignored: field not allowed
581+
}
582+
583+
self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
584+
response = self.client.post(reverse('projects-list'), data)
585+
self.assertEqual(response.status_code, 201)
586+
587+
query = Project.objects.filter(slug='test-project')
588+
self.assertTrue(query.exists())
589+
590+
project = query.first()
591+
self.assertEqual(project.name, 'Test Project')
592+
self.assertEqual(project.slug, 'test-project')
593+
self.assertEqual(project.repo, 'https://github.com/rtfd/template')
594+
self.assertNotEqual(project.default_version, 'v1.0')
595+
self.assertIn(self.me, project.users.all())

readthedocs/api/v3/views.py

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import django_filters.rest_framework as filters
22
from django.utils.safestring import mark_safe
33
from rest_flex_fields.views import FlexFieldsMixin
4+
from rest_framework import status
45
from rest_framework.authentication import TokenAuthentication
56
from rest_framework.decorators import action
67
from rest_framework.metadata import SimpleMetadata
@@ -20,8 +21,10 @@
2021
from readthedocs.builds.models import Build, Version
2122
from readthedocs.core.utils import trigger_build
2223
from readthedocs.projects.models import Project
24+
from readthedocs.projects.views.mixins import ProjectImportMixin
2325
from readthedocs.redirects.models import Redirect
2426

27+
2528
from .filters import BuildFilter, ProjectFilter, VersionFilter
2629
from .mixins import ProjectQuerySetMixin
2730
from .permissions import PublicDetailPrivateListing, IsProjectAdmin
@@ -30,6 +33,7 @@
3033
BuildCreateSerializer,
3134
BuildSerializer,
3235
ProjectSerializer,
36+
ProjectCreateSerializer,
3337
RedirectCreateSerializer,
3438
RedirectDetailSerializer,
3539
VersionSerializer,
@@ -66,7 +70,8 @@ class APIv3Settings:
6670

6771

6872
class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
69-
FlexFieldsMixin, ReadOnlyModelViewSet):
73+
FlexFieldsMixin, ProjectImportMixin, CreateModelMixin,
74+
ReadOnlyModelViewSet):
7075

7176
# Markdown docstring is automatically rendered by BrowsableAPIRenderer.
7277

@@ -114,14 +119,13 @@ class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
114119
* Subprojects of a project: ``/api/v3/projects/{project_slug}/subprojects/``
115120
* Superproject of a project: ``/api/v3/projects/{project_slug}/superproject/``
116121
117-
Go to [https://docs.readthedocs.io/en/stable/api/v3.html](https://docs.readthedocs.io/en/stable/api/v3.html)
122+
Go to [https://docs.readthedocs.io/page/api/v3.html](https://docs.readthedocs.io/page/api/v3.html)
118123
for a complete documentation of the APIv3.
119124
""" # noqa
120125

121126
model = Project
122127
lookup_field = 'slug'
123128
lookup_url_kwarg = 'project_slug'
124-
serializer_class = ProjectSerializer
125129
filterset_class = ProjectFilter
126130
queryset = Project.objects.all()
127131
permit_list_expands = [
@@ -130,6 +134,21 @@ class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
130134
'active_versions.last_build.config',
131135
]
132136

137+
def get_serializer_class(self):
138+
"""
139+
Return correct serializer depending on the action.
140+
141+
For GET it returns a serializer with many fields and on PUT/PATCH/POST,
142+
it return a serializer to validate just a few fields.
143+
"""
144+
if self.action in ('list', 'retrieve', 'superproject'):
145+
# NOTE: ``superproject`` is the @action defined in the
146+
# ProjectViewSet that returns the superproject of a project.
147+
return ProjectSerializer
148+
149+
if self.action == 'create':
150+
return ProjectCreateSerializer
151+
133152
def get_queryset(self):
134153
# Allow hitting ``/api/v3/projects/`` to list their own projects
135154
if self.basename == 'projects' and self.action == 'list':
@@ -165,6 +184,32 @@ def get_view_description(self, *args, **kwargs): # pylint: disable=arguments-di
165184
return mark_safe(description.format(project_slug=project.slug))
166185
return description
167186

187+
def create(self, request, *args, **kwargs):
188+
"""
189+
Import Project.
190+
191+
Override to use a different serializer in the response.
192+
"""
193+
serializer = self.get_serializer(data=request.data)
194+
serializer.is_valid(raise_exception=True)
195+
self.perform_create(serializer)
196+
headers = self.get_success_headers(serializer.data)
197+
198+
# Use serializer that fully render a Project
199+
serializer = ProjectSerializer(instance=serializer.instance)
200+
201+
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
202+
203+
def perform_create(self, serializer):
204+
"""
205+
Import Project.
206+
207+
Trigger our internal mechanism to import a project after it's saved in
208+
the database.
209+
"""
210+
project = serializer.save()
211+
self.finish_import_project(self.request, project)
212+
168213
@action(detail=True, methods=['get'])
169214
def superproject(self, request, project_slug):
170215
"""Return the superproject of a ``Project``."""
@@ -174,7 +219,7 @@ def superproject(self, request, project_slug):
174219
data = self.get_serializer(superproject).data
175220
return Response(data)
176221
except Exception:
177-
return Response(status=404)
222+
return Response(status=status.HTTP_404_NOT_FOUND)
178223

179224

180225
class SubprojectRelationshipViewSet(APIv3Settings, NestedViewSetMixin,
@@ -263,7 +308,7 @@ def update(self, request, *args, **kwargs):
263308
# ``httpOnly`` on our cookies and the ``PUT/PATCH`` method are triggered
264309
# via Javascript
265310
super().update(request, *args, **kwargs)
266-
return Response(status=204)
311+
return Response(status=status.HTTP_204_NO_CONTENT)
267312

268313

269314
class BuildsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
@@ -303,11 +348,11 @@ def create(self, request, **kwargs): # pylint: disable=arguments-differ
303348

304349
if build:
305350
data.update({'triggered': True})
306-
status = 202
351+
code = status.HTTP_202_ACCEPTED
307352
else:
308353
data.update({'triggered': False})
309-
status = 400
310-
return Response(data=data, status=status)
354+
code = status.HTTP_400_BAD_REQUEST
355+
return Response(data=data, status=code)
311356

312357

313358
class RedirectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,

readthedocs/projects/views/mixins.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
"""Mixin classes for project views."""
44

5+
from celery import chain
56
from django.shortcuts import get_object_or_404
67

8+
from readthedocs.core.utils import prepare_build
79
from readthedocs.projects.models import Project
10+
from readthedocs.projects.signals import project_import
811

912

1013
class ProjectRelationMixin:
@@ -44,3 +47,55 @@ def get_context_data(self, **kwargs):
4447
context = super().get_context_data(**kwargs)
4548
context[self.project_context_object_name] = self.get_project()
4649
return context
50+
51+
52+
class ProjectImportMixin:
53+
54+
"""Helpers to import a Project."""
55+
56+
def finish_import_project(self, request, project, tags=None):
57+
"""
58+
Perform last steps to import a project into Read the Docs.
59+
60+
- Add the user from request as maintainer
61+
- Set all the tags to the project
62+
- Send Django Signal
63+
- Trigger initial build
64+
65+
It requires the Project was already saved into the DB.
66+
67+
:param request: Django Request object
68+
:param project: Project instance just imported (already saved)
69+
:param tags: tags to add to the project
70+
"""
71+
if not tags:
72+
tags = []
73+
74+
project.users.add(request.user)
75+
for tag in tags:
76+
project.tags.add(tag)
77+
78+
# TODO: this signal could be removed, or used for sync task
79+
project_import.send(sender=project, request=request)
80+
81+
self.trigger_initial_build(project, request.user)
82+
83+
def trigger_initial_build(self, project, user):
84+
"""
85+
Trigger initial build after project is imported.
86+
87+
:param project: project's documentation to be built
88+
:returns: Celery AsyncResult promise
89+
"""
90+
91+
update_docs, build = prepare_build(project)
92+
if (update_docs, build) == (None, None):
93+
return None
94+
95+
from readthedocs.oauth.tasks import attach_webhook
96+
task_promise = chain(
97+
attach_webhook.si(project.pk, user.pk),
98+
update_docs,
99+
)
100+
async_result = task_promise.apply_async()
101+
return async_result

0 commit comments

Comments
 (0)