diff --git a/docs/api/v3.rst b/docs/api/v3.rst index e4788083fc9..2c79cc12c6b 100644 --- a/docs/api/v3.rst +++ b/docs/api/v3.rst @@ -561,6 +561,34 @@ This allows for documentation projects to share a search index and a namespace o but still be maintained independently. See :doc:`/subprojects` for more information. + +Subproject details +++++++++++++++++++ + + +.. http:get:: /api/v3/projects/(str:project_slug)/subprojects/(str:alias_slug)/ + + Retrieve details of a subproject relationship. + + **Example request**: + + .. sourcecode:: bash + + $ curl -H "Authorization: Token " https://readthedocs.org/api/v3/projects/pip/subprojects/subproject-alias/ + + **Example response**: + + .. sourcecode:: json + + { + "alias": "subproject-alias", + "child": ["PROJECT"], + "_links": { + "parent": "/api/v3/projects/pip/" + } + } + + Subprojects listing +++++++++++++++++++ @@ -583,15 +611,64 @@ Subprojects listing "count": 25, "next": "/api/v3/projects/pip/subprojects/?limit=10&offset=10", "previous": null, - "results": ["PROJECT"] + "results": ["SUBPROJECT RELATIONSHIP"] } - :>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. +Subproject create ++++++++++++++++++ + + +.. http:post:: /api/v3/projects/(str:project_slug)/subprojects/ + + Create a subproject relationship between two projects. + + **Example request**: + + .. sourcecode:: bash + + $ curl \ + -X POST \ + -H "Authorization: Token " https://readthedocs.org/api/v3/projects/pip/subprojects/ \ + -H "Content-Type: application/json" \ + -d @body.json + + The content of ``body.json`` is like, + + .. sourcecode:: json + + { + "child": "subproject-child-slug", + "alias": "subproject-alias" + } + + **Example response**: + + `See Subproject details <#subproject-details>`_ + + :>json string child: slug of the child project in the relationship. + :>json string alias: optional slug alias to be used in the URL (e.g ``/projects//en/latest/``). + If not provided, child project's slug is used as alias. + + :statuscode 201: Subproject created sucessfully + + +Subproject delete ++++++++++++++++++ + +.. http:delete:: /api/v3/projects/(str:project_slug)/subprojects/(str:alias_slug)/ + + Delete a subproject relationship. + + **Example request**: + + .. sourcecode:: bash + + $ curl \ + -X DELETE \ + -H "Authorization: Token " https://readthedocs.org/api/v3/projects/pip/subprojects/subproject-alias/ + + :statuscode 204: Subproject deleted successfully Translations diff --git a/readthedocs/api/v3/mixins.py b/readthedocs/api/v3/mixins.py index bd608d1a793..21c152778fa 100644 --- a/readthedocs/api/v3/mixins.py +++ b/readthedocs/api/v3/mixins.py @@ -13,6 +13,7 @@ class NestedParentObjectMixin: PROJECT_LOOKUP_NAMES = [ 'project__slug', 'projects__slug', + 'parent__slug', 'superprojects__parent__slug', 'main_language_project__slug', ] diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py index 44a90721e33..992de2cbace 100644 --- a/readthedocs/api/v3/serializers.py +++ b/readthedocs/api/v3/serializers.py @@ -18,7 +18,7 @@ PRIVACY_CHOICES, PROTECTED, ) -from readthedocs.projects.models import Project, EnvironmentVariable +from readthedocs.projects.models import Project, EnvironmentVariable, ProjectRelationship from readthedocs.redirects.models import Redirect, TYPE_CHOICES as REDIRECT_TYPE_CHOICES @@ -385,7 +385,7 @@ def get_subprojects(self, obj): path = reverse( 'projects-subprojects-list', kwargs={ - 'parent_lookup_superprojects__parent__slug': obj.slug, + 'parent_lookup_parent__slug': obj.slug, }, ) return self._absolute_url(path) @@ -539,6 +539,128 @@ def get_subproject_of(self, obj): return None +class SubprojectCreateSerializer(FlexFieldsModelSerializer): + + """Serializer used to define a Project as subproject of another Project.""" + + child = serializers.SlugRelatedField( + slug_field='slug', + queryset=Project.objects.all(), + ) + + class Meta: + model = ProjectRelationship + fields = [ + 'child', + 'alias', + ] + + def __init__(self, *args, **kwargs): + # Initialize the instance with the parent Project to be used in the + # serializer validation. + self.parent_project = kwargs.pop('parent') + super().__init__(*args, **kwargs) + + def validate_child(self, value): + # Check the user is maintainer of the child project + user = self.context['request'].user + if user not in value.users.all(): + raise serializers.ValidationError( + 'You do not have permissions on the child project', + ) + return value + + def validate_alias(self, value): + # Check there is not a subproject with this alias already + subproject = self.parent_project.subprojects.filter(alias=value) + if subproject.exists(): + raise serializers.ValidationError( + 'A subproject with this alias already exists', + ) + return value + + # pylint: disable=arguments-differ + def validate(self, data): + # Check the parent and child are not the same project + if data['child'].slug == self.parent_project.slug: + raise serializers.ValidationError( + 'Project can not be subproject of itself', + ) + + # Check the parent project is not a subproject already + if self.parent_project.superprojects.exists(): + raise serializers.ValidationError( + 'Subproject nesting is not supported', + ) + return data + + +class SubprojectLinksSerializer(BaseLinksSerializer): + _self = serializers.SerializerMethodField() + parent = serializers.SerializerMethodField() + + def get__self(self, obj): + path = reverse( + 'projects-subprojects-detail', + kwargs={ + 'parent_lookup_parent__slug': obj.parent.slug, + 'alias_slug': obj.alias, + }, + ) + return self._absolute_url(path) + + def get_parent(self, obj): + path = reverse( + 'projects-detail', + kwargs={ + 'project_slug': obj.parent.slug, + }, + ) + return self._absolute_url(path) + + +class ChildProjectSerializer(ProjectSerializer): + + """ + Serializer to render a Project when listed under ProjectRelationship. + + It's exactly the same as ``ProjectSerializer`` but without some fields. + """ + + class Meta(ProjectSerializer.Meta): + fields = [ + field for field in ProjectSerializer.Meta.fields + if field not in ['subproject_of'] + ] + + +class SubprojectSerializer(FlexFieldsModelSerializer): + + """Serializer to render a subproject (``ProjectRelationship``).""" + + child = ChildProjectSerializer() + _links = SubprojectLinksSerializer(source='*') + + class Meta: + model = ProjectRelationship + fields = [ + 'child', + 'alias', + '_links', + ] + + +class SubprojectDestroySerializer(FlexFieldsModelSerializer): + + """Serializer used to remove a subproject relationship to a Project.""" + + class Meta: + model = ProjectRelationship + fields = ( + 'alias', + ) + + class RedirectLinksSerializer(BaseLinksSerializer): _self = serializers.SerializerMethodField() project = serializers.SerializerMethodField() diff --git a/readthedocs/api/v3/tests/mixins.py b/readthedocs/api/v3/tests/mixins.py index 263aaf7bc59..d3ff2682f14 100644 --- a/readthedocs/api/v3/tests/mixins.py +++ b/readthedocs/api/v3/tests/mixins.py @@ -60,22 +60,6 @@ def setUp(self): project=self.project, ) - self.subproject = fixture.get( - Project, - pub_date=self.created, - modified_date=self.modified, - description='SubProject description', - repo='https://github.com/rtfd/subproject', - project_url='http://subproject.com', - name='subproject', - slug='subproject', - related_projects=[], - main_language_project=None, - users=[], - versions=[], - ) - self.project.add_subproject(self.subproject) - self.version = fixture.get( Version, slug='v1.0', @@ -119,6 +103,41 @@ def tearDown(self): # Cleanup cache to avoid throttling on tests cache.clear() + def _create_new_project(self): + """Helper to create a project with all the fields set.""" + return fixture.get( + Project, + pub_date=self.created, + modified_date=self.modified, + description='Project description', + repo='https://github.com/rtfd/project', + project_url='http://project.com', + name='new-project', + slug='new-project', + related_projects=[], + main_language_project=None, + users=[self.me], + versions=[], + ) + + def _create_subproject(self): + """Helper to create a sub-project with all the fields set.""" + self.subproject = fixture.get( + Project, + pub_date=self.created, + modified_date=self.modified, + description='SubProject description', + repo='https://github.com/rtfd/subproject', + project_url='http://subproject.com', + name='subproject', + slug='subproject', + related_projects=[], + main_language_project=None, + users=[self.me], + versions=[], + ) + self.project_relationship = self.project.add_subproject(self.subproject) + def _get_response_dict(self, view_name): filename = Path(__file__).absolute().parent / 'responses' / f'{view_name}.json' return json.load(open(filename)) diff --git a/readthedocs/api/v3/tests/responses/projects-detail.json b/readthedocs/api/v3/tests/responses/projects-detail.json index dafbee8d1b5..a5f61e9c112 100644 --- a/readthedocs/api/v3/tests/responses/projects-detail.json +++ b/readthedocs/api/v3/tests/responses/projects-detail.json @@ -4,7 +4,7 @@ "active": true, "built": true, "downloads": {}, - "id": 3, + "id": 2, "identifier": "a1b2c3", "last_build": { "commit": "a1b2c3", diff --git a/readthedocs/api/v3/tests/responses/projects-list_POST.json b/readthedocs/api/v3/tests/responses/projects-list_POST.json index 20ea4ce9edc..1bcb04caba4 100644 --- a/readthedocs/api/v3/tests/responses/projects-list_POST.json +++ b/readthedocs/api/v3/tests/responses/projects-list_POST.json @@ -12,7 +12,7 @@ "created": "2019-04-29T10:00:00Z", "default_branch": "master", "default_version": "latest", - "id": 4, + "id": 3, "homepage": "http://template.readthedocs.io/", "language": { "code": "en", diff --git a/readthedocs/api/v3/tests/responses/projects-subprojects-detail.json b/readthedocs/api/v3/tests/responses/projects-subprojects-detail.json new file mode 100644 index 00000000000..2966e4c0eb7 --- /dev/null +++ b/readthedocs/api/v3/tests/responses/projects-subprojects-detail.json @@ -0,0 +1,56 @@ +{ + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/subprojects/subproject/", + "parent": "https://readthedocs.org/api/v3/projects/project/" + }, + "alias": "subproject", + "child": { + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/subproject/", + "builds": "https://readthedocs.org/api/v3/projects/subproject/builds/", + "environmentvariables": "https://readthedocs.org/api/v3/projects/subproject/environmentvariables/", + "redirects": "https://readthedocs.org/api/v3/projects/subproject/redirects/", + "subprojects": "https://readthedocs.org/api/v3/projects/subproject/subprojects/", + "superproject": "https://readthedocs.org/api/v3/projects/subproject/superproject/", + "translations": "https://readthedocs.org/api/v3/projects/subproject/translations/", + "versions": "https://readthedocs.org/api/v3/projects/subproject/versions/" + }, + "created": "2019-04-29T10:00:00Z", + "default_branch": "master", + "default_version": "latest", + "homepage": "http://subproject.com", + "id": 3, + "language": { + "code": "en", + "name": "English" + }, + "modified": "2019-04-29T12:00:00Z", + "name": "subproject", + "privacy_level": { + "code": "public", + "name": "Public" + }, + "programming_language": { + "code": "words", + "name": "Only Words" + }, + "repository": { + "type": "git", + "url": "https://github.com/rtfd/subproject" + }, + "slug": "subproject", + "tags": [], + "translation_of": null, + "urls": { + "builds": "https://readthedocs.org/projects/subproject/builds/", + "documentation": "http://readthedocs.org/docs/project/projects/subproject/en/latest/", + "home": "https://readthedocs.org/projects/subproject/", + "versions": "https://readthedocs.org/projects/subproject/versions/" + }, + "users": [ + { + "username": "testuser" + } + ] + } +} diff --git a/readthedocs/api/v3/tests/responses/projects-subprojects-list.json b/readthedocs/api/v3/tests/responses/projects-subprojects-list.json index e5df1382523..79c2f06c54d 100644 --- a/readthedocs/api/v3/tests/responses/projects-subprojects-list.json +++ b/readthedocs/api/v3/tests/responses/projects-subprojects-list.json @@ -4,102 +4,59 @@ "previous": null, "results": [ { - "id": 2, - "name": "subproject", - "slug": "subproject", - "created": "2019-04-29T10:00:00Z", - "modified": "2019-04-29T12:00:00Z", - "language": { - "code": "en", - "name": "English" - }, - "programming_language": { - "code": "words", - "name": "Only Words" - }, - "homepage": "http://subproject.com", - "repository": { - "url": "https://github.com/rtfd/subproject", - "type": "git" - }, - "default_version": "latest", - "default_branch": "master", - "privacy_level": { - "code": "public", - "name": "Public" + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/subprojects/subproject/", + "parent": "https://readthedocs.org/api/v3/projects/project/" }, - "subproject_of": { - "id": 1, - "name": "project", - "slug": "project", + "alias": "subproject", + "child": { + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/subproject/", + "builds": "https://readthedocs.org/api/v3/projects/subproject/builds/", + "environmentvariables": "https://readthedocs.org/api/v3/projects/subproject/environmentvariables/", + "redirects": "https://readthedocs.org/api/v3/projects/subproject/redirects/", + "subprojects": "https://readthedocs.org/api/v3/projects/subproject/subprojects/", + "superproject": "https://readthedocs.org/api/v3/projects/subproject/superproject/", + "translations": "https://readthedocs.org/api/v3/projects/subproject/translations/", + "versions": "https://readthedocs.org/api/v3/projects/subproject/versions/" + }, "created": "2019-04-29T10:00:00Z", - "modified": "2019-04-29T12:00:00Z", + "default_branch": "master", + "default_version": "latest", + "homepage": "http://subproject.com", + "id": 3, "language": { "code": "en", "name": "English" }, + "modified": "2019-04-29T12:00:00Z", + "name": "subproject", + "privacy_level": { + "code": "public", + "name": "Public" + }, "programming_language": { "code": "words", "name": "Only Words" }, - "homepage": "http://project.com", "repository": { - "url": "https://github.com/rtfd/project", - "type": "git" - }, - "default_version": "latest", - "default_branch": "master", - "privacy_level": { - "code": "public", - "name": "Public" + "type": "git", + "url": "https://github.com/rtfd/subproject" }, - "subproject_of": null, + "slug": "subproject", + "tags": [], "translation_of": null, "urls": { - "builds": "https://readthedocs.org/projects/project/builds/", - "documentation": "http://readthedocs.org/docs/project/en/latest/", - "home": "https://readthedocs.org/projects/project/", - "versions": "https://readthedocs.org/projects/project/versions/" - }, - "tags": [ - "tag", - "project", - "test" - ], - "_links": { - "_self": "https://readthedocs.org/api/v3/projects/project/", - "versions": "https://readthedocs.org/api/v3/projects/project/versions/", - "builds": "https://readthedocs.org/api/v3/projects/project/builds/", - "environmentvariables": "https://readthedocs.org/api/v3/projects/project/environmentvariables/", - "redirects": "https://readthedocs.org/api/v3/projects/project/redirects/", - "subprojects": "https://readthedocs.org/api/v3/projects/project/subprojects/", - "superproject": "https://readthedocs.org/api/v3/projects/project/superproject/", - "translations": "https://readthedocs.org/api/v3/projects/project/translations/" + "builds": "https://readthedocs.org/projects/subproject/builds/", + "documentation": "http://readthedocs.org/docs/project/projects/subproject/en/latest/", + "home": "https://readthedocs.org/projects/subproject/", + "versions": "https://readthedocs.org/projects/subproject/versions/" }, "users": [ { "username": "testuser" } ] - }, - "translation_of": null, - "urls": { - "builds": "https://readthedocs.org/projects/subproject/builds/", - "documentation": "http://readthedocs.org/docs/project/projects/subproject/en/latest/", - "home": "https://readthedocs.org/projects/subproject/", - "versions": "https://readthedocs.org/projects/subproject/versions/" - }, - "tags": [], - "users": [], - "_links": { - "_self": "https://readthedocs.org/api/v3/projects/subproject/", - "versions": "https://readthedocs.org/api/v3/projects/subproject/versions/", - "builds": "https://readthedocs.org/api/v3/projects/subproject/builds/", - "environmentvariables": "https://readthedocs.org/api/v3/projects/subproject/environmentvariables/", - "redirects": "https://readthedocs.org/api/v3/projects/subproject/redirects/", - "subprojects": "https://readthedocs.org/api/v3/projects/subproject/subprojects/", - "superproject": "https://readthedocs.org/api/v3/projects/subproject/superproject/", - "translations": "https://readthedocs.org/api/v3/projects/subproject/translations/" } } ] diff --git a/readthedocs/api/v3/tests/responses/projects-subprojects-list_POST.json b/readthedocs/api/v3/tests/responses/projects-subprojects-list_POST.json new file mode 100644 index 00000000000..1a02625f3a2 --- /dev/null +++ b/readthedocs/api/v3/tests/responses/projects-subprojects-list_POST.json @@ -0,0 +1,56 @@ +{ + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/subprojects/subproject-alias/", + "parent": "https://readthedocs.org/api/v3/projects/project/" + }, + "alias": "subproject-alias", + "child": { + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/new-project/", + "builds": "https://readthedocs.org/api/v3/projects/new-project/builds/", + "environmentvariables": "https://readthedocs.org/api/v3/projects/new-project/environmentvariables/", + "redirects": "https://readthedocs.org/api/v3/projects/new-project/redirects/", + "subprojects": "https://readthedocs.org/api/v3/projects/new-project/subprojects/", + "superproject": "https://readthedocs.org/api/v3/projects/new-project/superproject/", + "translations": "https://readthedocs.org/api/v3/projects/new-project/translations/", + "versions": "https://readthedocs.org/api/v3/projects/new-project/versions/" + }, + "created": "2019-04-29T10:00:00Z", + "default_branch": "master", + "default_version": "latest", + "homepage": "http://project.com", + "id": 4, + "language": { + "code": "en", + "name": "English" + }, + "modified": "2019-04-29T12:00:00Z", + "name": "new-project", + "privacy_level": { + "code": "public", + "name": "Public" + }, + "programming_language": { + "code": "words", + "name": "Only Words" + }, + "repository": { + "type": "git", + "url": "https://github.com/rtfd/project" + }, + "slug": "new-project", + "tags": [], + "translation_of": null, + "urls": { + "builds": "https://readthedocs.org/projects/new-project/builds/", + "documentation": "http://readthedocs.org/docs/project/projects/subproject-alias/en/latest/", + "home": "https://readthedocs.org/projects/new-project/", + "versions": "https://readthedocs.org/projects/new-project/versions/" + }, + "users": [ + { + "username": "testuser" + } + ] + } +} 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 6f099147022..088b49ef2c8 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 @@ -78,7 +78,7 @@ "active": true, "built": true, "downloads": {}, - "id": 3, + "id": 2, "identifier": "a1b2c3", "_links": { "_self": "https://readthedocs.org/api/v3/projects/project/versions/v1.0/", diff --git a/readthedocs/api/v3/tests/responses/projects-versions-detail.json b/readthedocs/api/v3/tests/responses/projects-versions-detail.json index 861d951f36e..bb488a1f1c4 100644 --- a/readthedocs/api/v3/tests/responses/projects-versions-detail.json +++ b/readthedocs/api/v3/tests/responses/projects-versions-detail.json @@ -2,7 +2,7 @@ "active": true, "built": true, "downloads": {}, - "id": 3, + "id": 2, "identifier": "a1b2c3", "_links": { "_self": "https://readthedocs.org/api/v3/projects/project/versions/v1.0/", diff --git a/readthedocs/api/v3/tests/test_projects.py b/readthedocs/api/v3/tests/test_projects.py index a74aad0ba83..447fbadd6be 100644 --- a/readthedocs/api/v3/tests/test_projects.py +++ b/readthedocs/api/v3/tests/test_projects.py @@ -41,6 +41,7 @@ def test_own_projects_detail(self): ) def test_projects_superproject(self): + self._create_subproject() self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') response = self.client.get( reverse( @@ -57,22 +58,6 @@ def test_projects_superproject(self): self._get_response_dict('projects-superproject'), ) - def test_projects_subprojects_list(self): - self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') - response = self.client.get( - reverse( - 'projects-subprojects-list', - kwargs={ - 'parent_lookup_superprojects__parent__slug': self.project.slug, - }, - ), - ) - self.assertEqual(response.status_code, 200) - self.assertDictEqual( - response.json(), - self._get_response_dict('projects-subprojects-list'), - ) - def test_others_projects_builds_list(self): self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') response = self.client.get( diff --git a/readthedocs/api/v3/tests/test_subprojects.py b/readthedocs/api/v3/tests/test_subprojects.py new file mode 100644 index 00000000000..460adf32ef9 --- /dev/null +++ b/readthedocs/api/v3/tests/test_subprojects.py @@ -0,0 +1,211 @@ +from .mixins import APIEndpointMixin +from django.urls import reverse +import django_dynamic_fixture as fixture + +from readthedocs.projects.models import Project + + +class SubprojectsEndpointTests(APIEndpointMixin): + + def setUp(self): + super().setUp() + self._create_subproject() + + def test_projects_subprojects_list(self): + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + response = self.client.get( + reverse( + 'projects-subprojects-list', + kwargs={ + 'parent_lookup_parent__slug': self.project.slug, + }, + ), + ) + self.assertEqual(response.status_code, 200) + self.assertDictEqual( + response.json(), + self._get_response_dict('projects-subprojects-list'), + ) + + def test_projects_subprojects_detail(self): + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + response = self.client.get( + reverse( + 'projects-subprojects-detail', + kwargs={ + 'parent_lookup_parent__slug': self.project.slug, + 'alias_slug': self.project_relationship.alias, + }), + ) + self.assertEqual(response.status_code, 200) + self.assertDictEqual( + response.json(), + self._get_response_dict('projects-subprojects-detail'), + ) + + def test_projects_subprojects_list_post(self): + newproject = self._create_new_project() + self.assertEqual(self.project.subprojects.count(), 1) + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + data = { + 'child': newproject.slug, + 'alias': 'subproject-alias', + } + response = self.client.post( + reverse( + 'projects-subprojects-list', + kwargs={ + 'parent_lookup_parent__slug': self.project.slug, + }, + ), + data, + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(self.project.subprojects.count(), 2) + + self.assertDictEqual( + response.json(), + self._get_response_dict('projects-subprojects-list_POST'), + ) + + def test_projects_subprojects_list_post_with_others_as_child(self): + self.assertEqual(self.project.subprojects.count(), 1) + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + data = { + 'child': self.others_project.slug, + 'alias': 'subproject-alias', + } + response = self.client.post( + reverse( + 'projects-subprojects-list', + kwargs={ + 'parent_lookup_parent__slug': self.project.slug, + }, + ), + data, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(self.project.subprojects.count(), 1) + + def test_projects_subprojects_list_post_with_subproject_of_itself(self): + self.assertEqual(self.project.subprojects.count(), 1) + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + data = { + 'child': self.project.slug, + 'alias': 'subproject-alias', + } + response = self.client.post( + reverse( + 'projects-subprojects-list', + kwargs={ + 'parent_lookup_parent__slug': self.project.slug, + }, + ), + data, + ) + self.assertEqual(response.status_code, 400) + self.assertIn( + 'Project can not be subproject of itself', + response.json()['non_field_errors'], + ) + self.assertEqual(self.project.subprojects.count(), 1) + + def test_projects_subprojects_list_post_nested_subproject(self): + newproject = self._create_new_project() + self.assertEqual(self.project.subprojects.count(), 1) + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + data = { + 'child': newproject.slug, + 'alias': 'subproject-alias', + } + response = self.client.post( + reverse( + 'projects-subprojects-list', + kwargs={ + 'parent_lookup_parent__slug': self.subproject.slug, + }, + ), + data, + ) + self.assertEqual(response.status_code, 400) + self.assertIn( + 'Subproject nesting is not supported', + response.json()['non_field_errors'], + ) + self.assertEqual(self.project.subprojects.count(), 1) + + def test_projects_subprojects_list_post_unique_alias(self): + newproject = self._create_new_project() + self.assertEqual(self.project.subprojects.count(), 1) + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + data = { + 'child': newproject.slug, + 'alias': 'subproject', # this alias is already set for another subproject + } + response = self.client.post( + reverse( + 'projects-subprojects-list', + kwargs={ + 'parent_lookup_parent__slug': self.project.slug, + }, + ), + data, + ) + self.assertEqual(response.status_code, 400) + self.assertIn( + 'A subproject with this alias already exists', + response.json()['alias'], + ) + self.assertEqual(self.project.subprojects.count(), 1) + + def test_projects_subprojects_list_post_with_others_as_parent(self): + self.assertEqual(self.others_project.subprojects.count(), 0) + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + data = { + 'child': self.project.slug, + 'alias': 'subproject-alias', + } + response = self.client.post( + reverse( + 'projects-subprojects-list', + kwargs={ + 'parent_lookup_parent__slug': self.others_project.slug, + }, + ), + data, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(self.others_project.subprojects.count(), 0) + + def test_projects_subprojects_detail_delete(self): + self.assertEqual(self.project.subprojects.count(), 1) + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + response = self.client.delete( + reverse( + 'projects-subprojects-detail', + kwargs={ + 'parent_lookup_parent__slug': self.project.slug, + 'alias_slug': self.project_relationship.alias, + }, + ), + ) + self.assertEqual(response.status_code, 204) + self.assertEqual(self.project.subprojects.count(), 0) + + def test_projects_subprojects_detail_delete_others_project(self): + newproject = self._create_new_project() + project_relationship = self.others_project.add_subproject(newproject) + self.assertEqual(self.others_project.subprojects.count(), 1) + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + response = self.client.delete( + reverse( + 'projects-subprojects-detail', + kwargs={ + 'parent_lookup_parent__slug': self.others_project.slug, + 'alias_slug': project_relationship.alias, + }, + ), + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(self.project.subprojects.count(), 1) diff --git a/readthedocs/api/v3/urls.py b/readthedocs/api/v3/urls.py index fb0d41b5981..c52da4fb062 100644 --- a/readthedocs/api/v3/urls.py +++ b/readthedocs/api/v3/urls.py @@ -27,7 +27,7 @@ r'subprojects', SubprojectRelationshipViewSet, basename='projects-subprojects', - parents_query_lookups=['superprojects__parent__slug'], + parents_query_lookups=['parent__slug'], ) # allows /api/v3/projects/pip/translations/ diff --git a/readthedocs/api/v3/views.py b/readthedocs/api/v3/views.py index 7d863a0b20e..332bf517aca 100644 --- a/readthedocs/api/v3/views.py +++ b/readthedocs/api/v3/views.py @@ -21,7 +21,7 @@ from readthedocs.builds.models import Build, Version from readthedocs.core.utils import trigger_build -from readthedocs.projects.models import Project, EnvironmentVariable +from readthedocs.projects.models import Project, EnvironmentVariable, ProjectRelationship from readthedocs.projects.views.mixins import ProjectImportMixin from readthedocs.redirects.models import Redirect @@ -39,6 +39,9 @@ ProjectUpdateSerializer, RedirectCreateSerializer, RedirectDetailSerializer, + SubprojectCreateSerializer, + SubprojectSerializer, + SubprojectDestroySerializer, VersionSerializer, VersionUpdateSerializer, ) @@ -231,11 +234,12 @@ def superproject(self, request, project_slug): class SubprojectRelationshipViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, FlexFieldsMixin, - ListModelMixin, GenericViewSet): + CreateModelMixin, DestroyModelMixin, + ReadOnlyModelViewSet): # Markdown docstring exposed at BrowsableAPIRenderer. - """List subprojects of a ``Project``.""" + """List subprojects (``ProjectRelationship``) of a ``Project``.""" # Private/Internal docstring @@ -244,11 +248,38 @@ class SubprojectRelationshipViewSet(APIv3Settings, NestedViewSetMixin, ``parents_query_lookups`` defined when registering the urls. """ # noqa - model = Project - lookup_field = 'slug' - lookup_url_kwarg = 'project_slug' - serializer_class = ProjectSerializer - queryset = Project.objects.all() + model = ProjectRelationship + lookup_field = 'alias' + lookup_url_kwarg = 'alias_slug' + queryset = ProjectRelationship.objects.all() + + def get_serializer_class(self): + """ + Return correct serializer depending on the action. + + For GET it returns a serializer with many fields and on POST, + it return a serializer to validate just a few fields. + """ + if self.action == 'create': + return SubprojectCreateSerializer + + if self.action == 'destroy': + return SubprojectDestroySerializer + + return SubprojectSerializer + + def create(self, request, *args, **kwargs): + """Define a Project as subproject of another Project.""" + parent = self._get_parent_project() + serializer = self.get_serializer(parent=parent, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(parent=parent) + headers = self.get_success_headers(serializer.data) + + # Use serializer that fully render a the subproject + serializer = SubprojectSerializer(instance=serializer.instance) + + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) class TranslationRelationshipViewSet(APIv3Settings, NestedViewSetMixin, diff --git a/readthedocs/projects/querysets.py b/readthedocs/projects/querysets.py index efd01c8026c..22d87d1eeee 100644 --- a/readthedocs/projects/querysets.py +++ b/readthedocs/projects/querysets.py @@ -137,7 +137,8 @@ def _add_user_repos(self, queryset, user=None): return self.all() if user.is_authenticated: projects_pk = user.projects.all().values_list('pk', flat=True) - user_queryset = self.filter(project__in=projects_pk) + kwargs = {f'{self.project_field}__in': projects_pk} + user_queryset = self.filter(**kwargs) queryset = user_queryset | queryset return queryset