diff --git a/docs/api/index.rst b/docs/api/index.rst index 8d0464d6e5d..173eefda508 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -11,3 +11,4 @@ from Read the Docs. :maxdepth: 3 v2 + v3 diff --git a/docs/api/v2.rst b/docs/api/v2.rst index c4a5200c844..fbdecf7ebfc 100644 --- a/docs/api/v2.rst +++ b/docs/api/v2.rst @@ -1,5 +1,5 @@ -API v2 -====== +API v2 (deprecated) +=================== The Read the Docs API uses :abbr:`REST (Representational State Transfer)`. JSON is returned by all API responses including errors @@ -7,16 +7,16 @@ and HTTP response status codes are to designate success and failure. .. note:: - A newer API v3 is in early development stages. + A newer and better API v3 is ready to use. Some improvements coming in v3 are: - * Search API - * Write access - * Simpler URLs which use slugs instead of numeric IDs + * Simpler URLs which use slugs + * Token based authentication + * Import a new project + * Activate a version * Improved error reporting - If there are features you would like in v3, please get in touch - in the `issue tracker `_. + See its full documentation at :doc:`/api/v3`. Authentication and authorization diff --git a/docs/api/v3.rst b/docs/api/v3.rst index f5849cb104e..46ca9dd0b9e 100644 --- a/docs/api/v3.rst +++ b/docs/api/v3.rst @@ -1,5 +1,3 @@ -:orphan: - API v3 ====== @@ -7,17 +5,6 @@ The Read the Docs API uses :abbr:`REST (Representational State Transfer)`. JSON is returned by all API responses including errors and HTTP response status codes are to designate success and failure. -.. warning:: - - APIv3 is currently under development and it's not ready to use yet. - - -.. note:: - - If you want to beta test it, please `get in touch`_ with us so we can give you early access. - -.. _get in touch: mailto:support@readthedocs.org?subject=APIv3%20beta%20test - .. contents:: Table of contents :local: :backlinks: none @@ -37,6 +24,11 @@ Token The ``Authorization`` HTTP header can be specified with ``Token `` to authenticate as a user and have the same permissions that the user itself. +.. note:: + + You will find your access Token under + `your profile settings `_. + Session ~~~~~~~ @@ -55,6 +47,27 @@ When a user is trying to authenticate via session, Resources --------- +This section shows all the resources that are currently available in APIv3. +There are some URL attributes that applies to all of these resources: + +:?fields=: + + Specify which fields are going to be returned in the response. + +:?omit=: + + Specify which fields are going to be omitted from the response. + +:?expand=: + + Some resources allow to expand/add extra fields on their responses (see `Project details <#project-details>`__ for example). + + +.. tip:: + + You can browse the full API by accessing its root URL: https://readthedocs.org/api/v3/ + + Projects ~~~~~~~~ @@ -82,21 +95,8 @@ Projects list "results": ["PROJECT"] } - :>json integer count: total number of projects. - :>json string next: URI for next set of projects. - :>json string previous: URI for previous set of projects. - :>json array results: array of ``project`` objects. - - :query string name: name of the project. - :query string name__contains: part of the name of the project. - :query string slug: slug of the project. - :query string slug__contains: part of the slug of the project. :query string language: language code as ``en``, ``es``, ``ru``, etc. - :query string privacy_level: one of ``public``, ``private``, ``protected``. :query string programming_language: programming language code as ``py``, ``js``, etc. - :query string repository_type: one of ``git``, ``hg``, ``bzr``, ``svn``. - - :requestheader Authorization: token to authenticate. Project details @@ -136,10 +136,6 @@ Project details }, "default_version": "stable", "default_branch": "master", - "privacy_level": { - "code": "public", - "name": "Public", - }, "subproject_of": null, "translation_of": null, "urls": { @@ -174,15 +170,9 @@ Project details } } - :>json string name: The name of the project. - :>json string slug: The project slug (used in the URL). - - .. TODO: complete the returned data docs once agreed on this. - - :requestheader Authorization: token to authenticate. - - :statuscode 200: Success - :statuscode 404: There is no ``Project`` with this slug + :query string expand: allows to add/expand some extra fields in the response. + Allowed values are ``active_versions``, ``active_versions.last_build`` and + ``active_versions.last_build.config``. Multiple fields can be passed separated by commas. Project create @@ -190,7 +180,7 @@ Project create .. http:post:: /api/v3/projects/ - Import a project into Read the Docs. + Import a project under authenticated user. **Example request**: @@ -219,13 +209,7 @@ Project create **Example response**: - `See Project details <#project-details>`_ - - :requestheader Authorization: token to authenticate. - - :statuscode 201: Created - :statuscode 400: Some field is invalid - + `See Project details <#project-details>`__ Project update ++++++++++++++ @@ -292,17 +276,8 @@ Versions listing "results": ["VERSION"] } - :>json integer count: Total number of Projects. - :>json string next: URI for next set of Projects. - :>json string previous: URI for previous set of Projects. - :>json array results: Array of ``Version`` objects. - - :query integer limit: limit number of object returned - :query integer offset: offset from the whole list returned - :query boolean active: whether return active versions only - :query boolean built: whether return only built version - - :requestheader Authorization: token to authenticate. + :query boolean active: return only active versions + :query boolean built: return only built versions Version detail @@ -366,19 +341,23 @@ Version update **Example request**: + .. sourcecode:: bash + + $ curl \ + -X PATCH \ + -H "Authorization: Token " https://readthedocs.org/api/v3/projects/pip/version/0.23/ \ + -H "Content-Type: application/json" \ + -d @body.json + + The content of ``body.json`` is like, + .. sourcecode:: json { "active": true, - "privacy_level": "public" } - :requestheader Authorization: token to authenticate. - :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 Builds @@ -468,22 +447,14 @@ Build details } } - :>json integer id: The ID of the build - :>json string date: The ISO-8601 datetime of the build. + :>json string created: The ISO-8601 datetime when the build was created. + :>json string finished: The ISO-8601 datetime when the build has finished. :>json integer duration: The length of the build in seconds. :>json string state: The state of the build (one of ``triggered``, ``building``, ``installing``, ``cloning``, or ``finished``) - :>json boolean success: Whether the build was successful :>json string error: An error message if the build was unsuccessful - :>json string commit: A version control identifier for this build (eg. the commit hash) - :>json string builder: The hostname server that built the docs - :>json string cold_storage: Whether the build was removed from database and stored externally - - :query boolean include_config: whether or not include the configs used for this build. Default is ``false`` - :requestheader Authorization: token to authenticate. - - :statuscode 200: Success - :statuscode 404: There is no ``Build`` with this ID + :query string expand: allows to add/expand some extra fields in the response. + Allowed value is ``config``. Builds listing @@ -511,9 +482,7 @@ Builds listing } :query string commit: commit hash to filter the builds returned by commit - :query boolean running: whether or not to filter the builds returned by currently building - - :requestheader Authorization: token to authenticate. + :query boolean running: filter the builds that are currently building/running Build triggering @@ -534,13 +503,15 @@ Build triggering **Example response**: - `See Build details <#build-details>`_ + .. sourcecode:: json - :requestheader Authorization: token to authenticate. + { + "build": "{BUILD}", + "project": "{PROJECT}", + "version": "{VERSION}" + } - :statuscode 202: Accepted - :statuscode 400: Some field is invalid - :statuscode 401: Not valid permissions + :statuscode 202: the build was triggered Subprojects @@ -693,13 +664,6 @@ Translations listing "results": ["PROJECT"] } - :>json integer count: total number of projects. - :>json string next: URI for next set of projects. - :>json string previous: URI for previous set of projects. - :>json array results: array of ``project`` objects. - - :requestheader Authorization: token to authenticate. - Redirects ~~~~~~~~~ @@ -791,14 +755,21 @@ Redirect create "type": "page" } + .. note:: + + ``type`` can be one of ``prefix``, ``page``, ``exact``, ``sphinx_html`` and ``sphinx_htmldir``. + + Depending on the ``type`` of the redirect, some fields may not be needed: + + * ``prefix`` type does not require ``to_url``. + * ``page`` and ``exact`` types require ``from_url`` and ``to_url``. + * ``sphinx_html`` and ``sphinx_htmldir`` types do not require ``from_url`` and ``to_url``. + **Example response**: `See Redirect details <#redirect-details>`_ - :requestheader Authorization: token to authenticate. - - :statuscode 201: Created - :statuscode 400: Some field is invalid + :statuscode 201: redirect created successfully Redirect update @@ -832,12 +803,6 @@ Redirect update `See Redirect details <#redirect-details>`_ - :requestheader Authorization: token to authenticate. - - :statuscode 200: Success - :statuscode 400: Some field is invalid - - Redirect delete ++++++++++++++++ @@ -853,9 +818,7 @@ Redirect delete -X DELETE \ -H "Authorization: Token " https://readthedocs.org/api/v3/projects/pip/redirects/1/ - :requestheader Authorization: token to authenticate. - - :statuscode 204: Deleted successfully + :statuscode 204: Redirect deleted successfully Environment Variables @@ -950,10 +913,7 @@ Environment Variable create `See Environment Variable details <#environmentvariable-details>`_ - :requestheader Authorization: token to authenticate. - - :statuscode 201: Created - :statuscode 400: Some field is invalid + :statuscode 201: Environment variable created successfully Environment Variable delete @@ -973,4 +933,4 @@ Environment Variable delete :requestheader Authorization: token to authenticate. - :statuscode 204: Deleted successfully + :statuscode 204: Environment variable deleted successfully diff --git a/docs/development/design/apiv3.rst b/docs/development/design/apiv3.rst index 618023a2670..8c31e817498 100644 --- a/docs/development/design/apiv3.rst +++ b/docs/development/design/apiv3.rst @@ -70,10 +70,6 @@ Version 1 The first implementation of APIv3 will cover the following aspects: -.. note:: - - This is currently implemented and live. Although, it's only for internal testing. - * Authentication * all endpoints require authentication via ``Authorization:`` request header @@ -109,6 +105,10 @@ The first implementation of APIv3 will cover the following aspects: Version 2 +++++++++ +.. note:: + + This is currently implemented and live. + Second iteration will polish issues found from the first step, and add new endpoints to allow *import a project and configure it* without the needed of using the WebUI as a main goal. @@ -124,6 +124,7 @@ This iteration will include: * Edit Project attributes ("Settings" and "Advanced settings-Global settings" in the WebUI) * Trigger Build for default version * Allow CRUD for Redirect, Environment Variables and Notifications (``WebHook`` and ``EmailHook``) +* Create/Delete a Project as subproject of another Project * Documentation diff --git a/readthedocs/api/v3/filters.py b/readthedocs/api/v3/filters.py index b24ffb31b63..747efecd2aa 100644 --- a/readthedocs/api/v3/filters.py +++ b/readthedocs/api/v3/filters.py @@ -6,50 +6,21 @@ class ProjectFilter(filters.FilterSet): - name__contains = filters.CharFilter( - field_name='name', - lookup_expr='contains', - ) - slug__contains = filters.CharFilter( - field_name='slug', - lookup_expr='contains', - ) - repository_type = filters.CharFilter( - field_name='repo_type', - lookup_expr='exact', - ) class Meta: model = Project fields = [ - 'name', - 'name__contains', - 'slug', - 'slug__contains', 'language', - 'privacy_level', 'programming_language', - 'repository_type', ] class VersionFilter(filters.FilterSet): - verbose_name__contains = filters.CharFilter( - field_name='verbose_name', - lookup_expr='contains', - ) - slug__contains = filters.CharFilter( - field_name='slug', - lookup_expr='contains', - ) class Meta: model = Version fields = [ 'verbose_name', - 'verbose_name__contains', - 'slug', - 'slug__contains', 'privacy_level', 'active', 'built', diff --git a/readthedocs/api/v3/routers.py b/readthedocs/api/v3/routers.py index 90588c0997d..7b768b4b2fb 100644 --- a/readthedocs/api/v3/routers.py +++ b/readthedocs/api/v3/routers.py @@ -9,9 +9,9 @@ class DocsAPIRootView(APIRootView): """ Read the Docs APIv3 root endpoint. - API is browseable by sending the header ``Authorization: Token `` on each request. + The API is browseable by sending the header ``Authorization: Token `` on each request. You can find your Token at [https://readthedocs.org/accounts/tokens/](https://readthedocs.org/accounts/tokens/). - Full documentation at [https://docs.readthedocs.io/page/api/v3.html](https://docs.readthedocs.io/page/api/v3.html). + Read its full documentation at [https://docs.readthedocs.io/page/api/v3.html](https://docs.readthedocs.io/page/api/v3.html). """ # noqa def get_view_name(self): diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py index 5cd264110b6..5b3361259ef 100644 --- a/readthedocs/api/v3/serializers.py +++ b/readthedocs/api/v3/serializers.py @@ -159,14 +159,6 @@ def get_success(self, obj): return None -class PrivacyLevelSerializer(serializers.Serializer): - code = serializers.CharField(source='privacy_level') - name = serializers.SerializerMethodField() - - def get_name(self, obj): - return obj.privacy_level.title() - - class VersionLinksSerializer(BaseLinksSerializer): _self = serializers.SerializerMethodField() builds = serializers.SerializerMethodField() @@ -212,7 +204,6 @@ def get_documentation(self, obj): class VersionSerializer(FlexFieldsModelSerializer): - privacy_level = PrivacyLevelSerializer(source='*') ref = serializers.CharField() downloads = serializers.SerializerMethodField() urls = VersionURLsSerializer(source='*') @@ -228,7 +219,6 @@ class Meta: 'ref', 'built', 'active', - 'privacy_level', 'type', 'downloads', 'urls', @@ -257,14 +247,13 @@ class VersionUpdateSerializer(serializers.ModelSerializer): """ Used when modifying (update action) a ``Version``. - It only allows to make the Version active/non-active and private/public. + It only allows to make the Version active/non-active. """ class Meta: model = Version fields = [ 'active', - 'privacy_level', ] @@ -434,11 +423,6 @@ class ProjectUpdateSerializer(FlexFieldsModelSerializer): 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 = ( @@ -452,7 +436,6 @@ class Meta: # Advanced Settings -> General Settings 'default_version', 'default_branch', - 'privacy_level', 'analytics_code', 'show_version_warning', 'single_version', @@ -468,7 +451,6 @@ class ProjectSerializer(FlexFieldsModelSerializer): language = LanguageSerializer() programming_language = ProgrammingLanguageSerializer() repository = RepositorySerializer(source='*') - privacy_level = PrivacyLevelSerializer(source='*') urls = ProjectURLsSerializer(source='*') subproject_of = serializers.SerializerMethodField() translation_of = serializers.SerializerMethodField() @@ -497,7 +479,6 @@ class Meta: 'repository', 'default_version', 'default_branch', - 'privacy_level', 'subproject_of', 'translation_of', 'users', diff --git a/readthedocs/api/v3/tests/responses/projects-detail.json b/readthedocs/api/v3/tests/responses/projects-detail.json index a5f61e9c112..9b01788d463 100644 --- a/readthedocs/api/v3/tests/responses/projects-detail.json +++ b/readthedocs/api/v3/tests/responses/projects-detail.json @@ -34,10 +34,6 @@ "builds": "https://readthedocs.org/api/v3/projects/project/versions/v1.0/builds/", "project": "https://readthedocs.org/api/v3/projects/project/" }, - "privacy_level": { - "code": "public", - "name": "Public" - }, "ref": null, "slug": "v1.0", "type": "tag", @@ -68,10 +64,6 @@ }, "modified": "2019-04-29T12:00:00Z", "name": "project", - "privacy_level": { - "code": "public", - "name": "Public" - }, "programming_language": { "code": "words", "name": "Only Words" diff --git a/readthedocs/api/v3/tests/responses/projects-list.json b/readthedocs/api/v3/tests/responses/projects-list.json index 2313c2247cb..624cc03ba9b 100644 --- a/readthedocs/api/v3/tests/responses/projects-list.json +++ b/readthedocs/api/v3/tests/responses/projects-list.json @@ -24,10 +24,6 @@ }, "default_version": "latest", "default_branch": "master", - "privacy_level": { - "code": "public", - "name": "Public" - }, "subproject_of": null, "translation_of": null, "urls": { diff --git a/readthedocs/api/v3/tests/responses/projects-list_POST.json b/readthedocs/api/v3/tests/responses/projects-list_POST.json index 1bcb04caba4..7c507ad2db9 100644 --- a/readthedocs/api/v3/tests/responses/projects-list_POST.json +++ b/readthedocs/api/v3/tests/responses/projects-list_POST.json @@ -20,10 +20,6 @@ }, "modified": "2019-04-29T12:00:00Z", "name": "Test Project", - "privacy_level": { - "code": "public", - "name": "Public" - }, "programming_language": { "code": "py", "name": "Python" diff --git a/readthedocs/api/v3/tests/responses/projects-subprojects-detail.json b/readthedocs/api/v3/tests/responses/projects-subprojects-detail.json index 2966e4c0eb7..2780033f9b7 100644 --- a/readthedocs/api/v3/tests/responses/projects-subprojects-detail.json +++ b/readthedocs/api/v3/tests/responses/projects-subprojects-detail.json @@ -26,10 +26,6 @@ }, "modified": "2019-04-29T12:00:00Z", "name": "subproject", - "privacy_level": { - "code": "public", - "name": "Public" - }, "programming_language": { "code": "words", "name": "Only Words" diff --git a/readthedocs/api/v3/tests/responses/projects-subprojects-list.json b/readthedocs/api/v3/tests/responses/projects-subprojects-list.json index 79c2f06c54d..4355f4abdfe 100644 --- a/readthedocs/api/v3/tests/responses/projects-subprojects-list.json +++ b/readthedocs/api/v3/tests/responses/projects-subprojects-list.json @@ -31,10 +31,6 @@ }, "modified": "2019-04-29T12:00:00Z", "name": "subproject", - "privacy_level": { - "code": "public", - "name": "Public" - }, "programming_language": { "code": "words", "name": "Only Words" diff --git a/readthedocs/api/v3/tests/responses/projects-subprojects-list_POST.json b/readthedocs/api/v3/tests/responses/projects-subprojects-list_POST.json index 1a02625f3a2..4c3af534fcb 100644 --- a/readthedocs/api/v3/tests/responses/projects-subprojects-list_POST.json +++ b/readthedocs/api/v3/tests/responses/projects-subprojects-list_POST.json @@ -26,10 +26,6 @@ }, "modified": "2019-04-29T12:00:00Z", "name": "new-project", - "privacy_level": { - "code": "public", - "name": "Public" - }, "programming_language": { "code": "words", "name": "Only Words" diff --git a/readthedocs/api/v3/tests/responses/projects-superproject.json b/readthedocs/api/v3/tests/responses/projects-superproject.json index 865cf46f812..8c8ce5284c3 100644 --- a/readthedocs/api/v3/tests/responses/projects-superproject.json +++ b/readthedocs/api/v3/tests/responses/projects-superproject.json @@ -19,10 +19,6 @@ }, "modified": "2019-04-29T12:00:00Z", "name": "project", - "privacy_level": { - "code": "public", - "name": "Public" - }, "programming_language": { "code": "words", "name": "Only Words" diff --git a/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json b/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json index 088b49ef2c8..2fdfc53b7aa 100644 --- a/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json +++ b/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json @@ -40,10 +40,6 @@ }, "modified": "2019-04-29T12:00:00Z", "name": "project", - "privacy_level": { - "code": "public", - "name": "Public" - }, "programming_language": { "code": "words", "name": "Only Words" @@ -85,10 +81,6 @@ "builds": "https://readthedocs.org/api/v3/projects/project/versions/v1.0/builds/", "project": "https://readthedocs.org/api/v3/projects/project/" }, - "privacy_level": { - "code": "public", - "name": "Public" - }, "ref": null, "slug": "v1.0", "type": "tag", diff --git a/readthedocs/api/v3/tests/responses/projects-versions-detail.json b/readthedocs/api/v3/tests/responses/projects-versions-detail.json index bb488a1f1c4..725c3680b8a 100644 --- a/readthedocs/api/v3/tests/responses/projects-versions-detail.json +++ b/readthedocs/api/v3/tests/responses/projects-versions-detail.json @@ -9,10 +9,6 @@ "builds": "https://readthedocs.org/api/v3/projects/project/versions/v1.0/builds/", "project": "https://readthedocs.org/api/v3/projects/project/" }, - "privacy_level": { - "code": "public", - "name": "Public" - }, "ref": null, "slug": "v1.0", "type": "tag", diff --git a/readthedocs/api/v3/tests/test_projects.py b/readthedocs/api/v3/tests/test_projects.py index 447fbadd6be..91547782349 100644 --- a/readthedocs/api/v3/tests/test_projects.py +++ b/readthedocs/api/v3/tests/test_projects.py @@ -207,7 +207,7 @@ def test_update_project(self): 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.privacy_level, 'public') self.assertEqual(self.project.analytics_code, 'UA-XXXXXX') self.assertEqual(self.project.show_version_warning, False) self.assertEqual(self.project.single_version, True) diff --git a/readthedocs/api/v3/tests/test_redirects.py b/readthedocs/api/v3/tests/test_redirects.py index e14f485e64e..02aa768fc83 100644 --- a/readthedocs/api/v3/tests/test_redirects.py +++ b/readthedocs/api/v3/tests/test_redirects.py @@ -1,6 +1,8 @@ from .mixins import APIEndpointMixin from django.urls import reverse +from readthedocs.redirects.models import Redirect + class RedirectsEndpointTests(APIEndpointMixin): @@ -117,6 +119,56 @@ def test_projects_redirects_list_post(self): self._get_response_dict('projects-redirects-list_POST'), ) + def test_projects_redirects_type_prefix_list_post(self): + self.assertEqual(Redirect.objects.count(), 1) + data = { + 'from_url': '/redirect-this/', + 'type': 'prefix', + } + + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + response = self.client.post( + reverse( + 'projects-redirects-list', + kwargs={ + 'parent_lookup_project__slug': self.project.slug, + }, + ), + data, + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(Redirect.objects.all().count(), 2) + + redirect = Redirect.objects.first() + self.assertEqual(redirect.redirect_type, 'prefix') + self.assertEqual(redirect.from_url, '/redirect-this/') + self.assertEqual(redirect.to_url, '') + + def test_projects_redirects_type_sphinx_html_list_post(self): + self.assertEqual(Redirect.objects.count(), 1) + data = { + 'type': 'sphinx_html', + } + + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + response = self.client.post( + reverse( + 'projects-redirects-list', + kwargs={ + 'parent_lookup_project__slug': self.project.slug, + }, + ), + data, + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(Redirect.objects.all().count(), 2) + + redirect = Redirect.objects.first() + self.assertEqual(redirect.redirect_type, 'sphinx_html') + self.assertEqual(redirect.from_url, '') + self.assertEqual(redirect.to_url, '') + + def test_projects_redirects_detail_put(self): data = { 'from_url': '/changed/', diff --git a/readthedocs/api/v3/views.py b/readthedocs/api/v3/views.py index 332bf517aca..2668915c4d3 100644 --- a/readthedocs/api/v3/views.py +++ b/readthedocs/api/v3/views.py @@ -80,56 +80,6 @@ class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, UpdateMixin, UpdateModelMixin, ReadOnlyModelViewSet): - # Markdown docstring is automatically rendered by BrowsableAPIRenderer. - - """ - Endpoints related to ``Project`` objects. - - * Listing objects. - * Detailed object. - - Retrieving only needed data using ``?fields=`` URL attribute is allowed. - On the other hand, you can use ``?omit=`` and list the fields you want to skip in the response. - - ### Filters - - Allowed via URL attributes: - - * slug - * slug__contains - * name - * name__contains - - ### Expandable fields - - There are some fields that are not returned by default because they are - expensive to calculate. Although, they are available for those cases where - they are needed. - - Allowed via ``?expand=`` URL attribute: - - * users - * active_versions - * active_versions.last_build - * active_versions.last_build.confg - - - ### Examples: - - * List my projects: ``/api/v3/projects/`` - * List my projects with offset and limit: ``/api/v3/projects/?offset=10&limit=25`` - * Filter list: ``/api/v3/projects/?name__contains=test`` - * Retrieve only needed data: ``/api/v3/projects/?fields=slug,created`` - * Retrieve specific project: ``/api/v3/projects/{project_slug}/`` - * Expand required fields: ``/api/v3/projects/{project_slug}/?expand=active_versions`` - * Translations of a project: ``/api/v3/projects/{project_slug}/translations/`` - * Subprojects of a project: ``/api/v3/projects/{project_slug}/subprojects/`` - * Superproject of a project: ``/api/v3/projects/{project_slug}/superproject/`` - - Go to [https://docs.readthedocs.io/page/api/v3.html](https://docs.readthedocs.io/page/api/v3.html) - for a complete documentation of the APIv3. - """ # noqa - model = Project lookup_field = 'slug' lookup_url_kwarg = 'project_slug' @@ -177,23 +127,6 @@ def get_queryset(self): 'users', ) - def get_view_description(self, *args, **kwargs): # pylint: disable=arguments-differ - """ - Make valid links for the user's documentation browseable API. - - If the user has already one project, we pick the first and make all the - links for that project. Otherwise, we default to the placeholder. - """ - description = super().get_view_description(*args, **kwargs) - - project = None - if self.request and self.request.user.is_authenticated(): - project = self.request.user.projects.first() - if project: - # TODO: make the links clickable when ``kwargs.html=True`` - return mark_safe(description.format(project_slug=project.slug)) - return description - def create(self, request, *args, **kwargs): """ Import Project. @@ -237,16 +170,8 @@ class SubprojectRelationshipViewSet(APIv3Settings, NestedViewSetMixin, CreateModelMixin, DestroyModelMixin, ReadOnlyModelViewSet): - # Markdown docstring exposed at BrowsableAPIRenderer. - - """List subprojects (``ProjectRelationship``) of a ``Project``.""" - - # Private/Internal docstring - - """ - The main query is done via the ``NestedViewSetMixin`` using the - ``parents_query_lookups`` defined when registering the urls. - """ # noqa + # The main query is done via the ``NestedViewSetMixin`` using the + # ``parents_query_lookups`` defined when registering the urls. model = ProjectRelationship lookup_field = 'alias' @@ -286,15 +211,8 @@ class TranslationRelationshipViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, FlexFieldsMixin, ListModelMixin, GenericViewSet): - # Markdown docstring exposed at BrowsableAPIRenderer. - - """List translations of a ``Project``.""" - - # Private/Internal docstring - """ - The main query is done via the ``NestedViewSetMixin`` using the - ``parents_query_lookups`` defined when registering the urls. - """ # noqa + # The main query is done via the ``NestedViewSetMixin`` using the + # ``parents_query_lookups`` defined when registering the urls. model = Project lookup_field = 'slug' @@ -338,6 +256,7 @@ def get_serializer_class(self): class BuildsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, FlexFieldsMixin, ReadOnlyModelViewSet): + model = Build lookup_field = 'pk' lookup_url_kwarg = 'build_pk' diff --git a/readthedocs/profiles/urls/private.py b/readthedocs/profiles/urls/private.py index 6618059f337..be3c7b3f836 100644 --- a/readthedocs/profiles/urls/private.py +++ b/readthedocs/profiles/urls/private.py @@ -33,9 +33,19 @@ tokens_urls = [ url( r'^tokens/$', - views.TokenList.as_view(), + views.TokenListView.as_view(), name='profiles_tokens', ), + url( + r'^tokens/create/$', + views.TokenCreateView.as_view(), + name='profiles_tokens_create', + ), + url( + r'^tokens/delete/$', + views.TokenDeleteView.as_view(), + name='profiles_tokens_delete', + ), ] urlpatterns += tokens_urls diff --git a/readthedocs/profiles/views.py b/readthedocs/profiles/views.py index a05c46ba425..b37ac7eafe3 100644 --- a/readthedocs/profiles/views.py +++ b/readthedocs/profiles/views.py @@ -1,12 +1,21 @@ """Views for creating, editing and viewing site-specific user profiles.""" +from django.contrib import messages from django.contrib.auth import logout from django.contrib.auth.models import User from django.contrib.messages.views import SuccessMessageMixin +from django.http import HttpResponseRedirect from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from rest_framework.authtoken.models import Token -from vanilla import DetailView, FormView, ListView, UpdateView +from vanilla import ( + CreateView, + DeleteView, + DetailView, + FormView, + ListView, + UpdateView +) from readthedocs.core.forms import ( UserAdvertisingForm, @@ -94,16 +103,37 @@ class TokenMixin(PrivateViewMixin): template_name = 'profiles/private/token_list.html' def get_queryset(self): - # Token has a OneToOneField relation with User + # NOTE: we are currently showing just one token since the DRF model has + # a OneToOneField relation with User. Although, we plan to have multiple + # scope-based tokens. return Token.objects.filter(user__in=[self.request.user]) def get_success_url(self): - return reverse( - 'projects_token', - args=[self.get_project().slug], - ) - + return reverse('profiles_tokens') -class TokenList(TokenMixin, ListView): +class TokenListView(TokenMixin, ListView): pass + + +class TokenCreateView(TokenMixin, CreateView): + + """Simple view to generate a Token object for the logged in User.""" + + http_method_names = ['post'] + + def post(self, request, *args, **kwargs): + _, created = Token.objects.get_or_create(user=self.request.user) + if created: + messages.info(request, 'API Token created successfully') + return HttpResponseRedirect(self.get_success_url()) + + +class TokenDeleteView(TokenMixin, DeleteView): + + """View to delete/revoke the current Token of the logged in User.""" + + http_method_names = ['post'] + + def get_object(self, queryset=None): # noqa + return self.request.user.auth_token diff --git a/readthedocs/rtd_tests/tests/test_privacy_urls.py b/readthedocs/rtd_tests/tests/test_privacy_urls.py index d575f4ff106..df0506cd3b1 100644 --- a/readthedocs/rtd_tests/tests/test_privacy_urls.py +++ b/readthedocs/rtd_tests/tests/test_privacy_urls.py @@ -435,6 +435,12 @@ class PrivateUserProfileMixin(URLAccessMixin): def setUp(self): super().setUp() + + self.response_data.update({ + '/accounts/tokens/create/': {'status_code': 405}, + '/accounts/tokens/delete/': {'status_code': 405}, + }) + self.default_kwargs.update( { 'username': self.tester.username, @@ -469,6 +475,14 @@ class PrivateUserProfileUnauthAccessTest(PrivateUserProfileMixin, TestCase): # Auth protected default_status_code = 302 + def setUp(self): + super().setUp() + + self.response_data.update({ + '/accounts/tokens/create/': {'status_code': 302}, + '/accounts/tokens/delete/': {'status_code': 302}, + }) + def login(self): pass diff --git a/readthedocs/rtd_tests/tests/test_profile_views.py b/readthedocs/rtd_tests/tests/test_profile_views.py index b62521081b1..122fa01ffae 100644 --- a/readthedocs/rtd_tests/tests/test_profile_views.py +++ b/readthedocs/rtd_tests/tests/test_profile_views.py @@ -2,6 +2,7 @@ from django.test import TestCase from django.urls import reverse from django_dynamic_fixture import get +from rest_framework.authtoken.models import Token class ProfileViewsTest(TestCase): @@ -105,3 +106,31 @@ def test_account_advertising(self): self.assertEqual(resp['Location'], reverse('account_advertising')) self.user.profile.refresh_from_db() self.assertFalse(self.user.profile.allow_ads) + + def test_list_api_tokens(self): + resp = self.client.get(reverse('profiles_tokens')) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'No API Tokens currently configured.') + + Token.objects.create(user=self.user) + resp = self.client.get(reverse('profiles_tokens')) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, f'Token: {self.user.auth_token.key}') + + def test_create_api_token(self): + self.assertEqual(Token.objects.filter(user=self.user).count(), 0) + + resp = self.client.get(reverse('profiles_tokens_create')) + self.assertEqual(resp.status_code, 405) # GET not allowed + + resp = self.client.post(reverse('profiles_tokens_create')) + self.assertEqual(resp.status_code, 302) + self.assertEqual(Token.objects.filter(user=self.user).count(), 1) + + def test_delete_api_token(self): + Token.objects.create(user=self.user) + self.assertEqual(Token.objects.filter(user=self.user).count(), 1) + + resp = self.client.post(reverse('profiles_tokens_delete')) + self.assertEqual(resp.status_code, 302) + self.assertEqual(Token.objects.filter(user=self.user).count(), 0) diff --git a/readthedocs/templates/profiles/private/token_list.html b/readthedocs/templates/profiles/private/token_list.html index 810c7285f51..beab20b2059 100644 --- a/readthedocs/templates/profiles/private/token_list.html +++ b/readthedocs/templates/profiles/private/token_list.html @@ -9,16 +9,21 @@ {% block edit_content_header %} {% trans "API Tokens" %} {% endblock %} {% block edit_content %} -

- {% blocktrans trimmed with contact_email="support@readthedocs.org" %} - API Tokens are currently an invite-only Beta feature. - In case you want to test APIv3 and give us feedback on it, - please email us. - {% endblocktrans %} -

-

Personal Access Token are tokens that allow you to use the Read the Docs APIv3 being authenticated as yourself. See APIv3 documentation for more information.

+ {% if not object_list %} +
+
    +
  • +
    + {% csrf_token %} + +
    +
  • +
+
+ {% endif %} +
@@ -27,6 +32,16 @@
  • Created: {{ token.created }}
    Token: {{ token.key }}
    + +
      +
    • +
      + {% csrf_token %} + +
      +
    • +
    +
  • {% empty %}