diff --git a/readthedocs/api/v3/mixins.py b/readthedocs/api/v3/mixins.py index 9db1091600a..0c36d8ebb1a 100644 --- a/readthedocs/api/v3/mixins.py +++ b/readthedocs/api/v3/mixins.py @@ -94,9 +94,19 @@ def _get_parent_project(self): return get_object_or_404(Project, slug=slug) def _get_parent_build(self): + """ + Filter the build by the permissions of the current user. + + Build permissions depend not only on the project, but also on + the version, Build.objects.api takes all that into consideration. + """ project_slug = self._get_parent_object_lookup(self.PROJECT_LOOKUP_NAMES) build_pk = self._get_parent_object_lookup(self.BUILD_LOOKUP_NAMES) - return get_object_or_404(Build, pk=build_pk, project__slug=project_slug) + return get_object_or_404( + Build.objects.api(user=self.request.user), + pk=build_pk, + project__slug=project_slug, + ) def _get_parent_version(self): project_slug = self._get_parent_object_lookup(self.PROJECT_LOOKUP_NAMES) @@ -137,18 +147,13 @@ class ProjectQuerySetMixin(NestedParentObjectMixin): All APIv3 ViewSet should inherit this mixin, unless specific permissions required. In that case, a specific mixin for that case should be defined. - """ - - def detail_objects(self, queryset, user): - # Filter results by user - return queryset.api(user=user) - def listing_objects(self, queryset, user): - project = self._get_parent_project() - if self.has_admin_permission(user, project): - return queryset + .. note:: - return queryset.none() + When using nested views, the ``NestedViewSetMixin`` should be + used and should be before this mixin in the inheritance list. + So it can properly filter the queryset based on the parent object. + """ def has_admin_permission(self, user, project): # Use .only for small optimization @@ -163,25 +168,8 @@ def admin_projects(self, user): return Project.objects.for_admin_user(user=user) def get_queryset(self): - """ - Filter results based on user permissions. - - 1. returns ``Projects`` where the user is admin if ``/projects/`` is hit - 2. filters by parent ``project_slug`` (NestedViewSetMixin) - 2. returns ``detail_objects`` results if it's a detail view - 3. returns ``listing_objects`` results if it's a listing view - 4. raise a ``NotFound`` exception otherwise - """ - - # We need to have defined the class attribute as ``queryset = Model.objects.all()`` - queryset = super().get_queryset() - - # Detail requests are public - if self.detail: - return self.detail_objects(queryset, self.request.user) - - # List view are only allowed if user is owner of parent project - return self.listing_objects(queryset, self.request.user) + """Filter projects or related resources based on the permissions of the current user.""" + return self.model.objects.api(user=self.request.user) class OrganizationQuerySetMixin(NestedParentObjectMixin): @@ -191,18 +179,13 @@ class OrganizationQuerySetMixin(NestedParentObjectMixin): All APIv3 organizations' ViewSet should inherit this mixin, unless specific permissions required. In that case, a specific mixin for that case should be defined. - """ - - def detail_objects(self, queryset, user): - # Filter results by user - return queryset.for_user(user=user) - def listing_objects(self, queryset, user): - organization = self._get_parent_organization() - if self.has_admin_permission(user, organization): - return queryset + .. note:: - return queryset.none() + When using nested views, the ``NestedViewSetMixin`` should be + used and should be before this mixin in the inheritance list. + So it can properly filter the queryset based on the parent object. + """ def has_admin_permission(self, user, organization): if self.admin_organizations(user).filter(pk=organization.pk).exists(): @@ -221,25 +204,8 @@ def admin_organizations(self, user): return Organization.objects.for_admin_user(user=user) def get_queryset(self): - """ - Filter results based on user permissions. - - 1. returns ``Organizations`` where the user is admin if ``/organizations/`` is hit - 2. filters by parent ``organization_slug`` (NestedViewSetMixin) - 2. returns ``detail_objects`` results if it's a detail view - 3. returns ``listing_objects`` results if it's a listing view - 4. raise a ``NotFound`` exception otherwise - """ - - # We need to have defined the class attribute as ``queryset = Model.objects.all()`` - queryset = super().get_queryset() - - # Detail requests are public - if self.detail: - return self.detail_objects(queryset, self.request.user) - - # List view are only allowed if user is owner of parent project - return self.listing_objects(queryset, self.request.user) + """Filter organizations or related resources based on the permissions of the current user.""" + return self.model.objects.api(user=self.request.user) class UserQuerySetMixin(NestedParentObjectMixin): @@ -273,4 +239,4 @@ def update(self, request, *args, **kwargs): class RemoteQuerySetMixin: def get_queryset(self): - return super().get_queryset().api(self.request.user) + return self.model.objects.api(self.request.user) diff --git a/readthedocs/api/v3/permissions.py b/readthedocs/api/v3/permissions.py index 0c3765afe34..01663817cbf 100644 --- a/readthedocs/api/v3/permissions.py +++ b/readthedocs/api/v3/permissions.py @@ -1,6 +1,5 @@ -from rest_framework.permissions import BasePermission, IsAuthenticated +from rest_framework.permissions import BasePermission -from readthedocs.core.utils.extend import SettingsOverrideObject from readthedocs.subscriptions.constants import TYPE_EMBED_API from readthedocs.subscriptions.products import get_feature @@ -29,63 +28,16 @@ def has_permission(self, request, view): return True -class UserProjectsListing(BasePermission): +class IsCurrentUser(BasePermission): - """Allow access to ``/projects`` (user's projects listing).""" + """Grant permission if user is the same as the one being accessed.""" def has_permission(self, request, view): - if view.basename == "projects" and view.action in ( - "list", - "create", # used to create Form in BrowsableAPIRenderer - None, # needed for BrowsableAPIRenderer - ): - # hitting ``/projects/``, allowing + user = view._get_parent_user() + if user == request.user: return True -class PublicDetailPrivateListing(BasePermission): - - """ - Permission class for our custom use case. - - * Always give permission for a ``detail`` request - * Only give permission for ``listing`` request if user is admin of the project - - However, for notification endpoints we only allow users with access to the project. - """ - - def has_permission(self, request, view): - # NOTE: ``superproject`` is an action name, defined by the class - # method under ``ProjectViewSet``. We should apply the same - # permissions restrictions than for a detail action (since it only - # returns one superproject if exists). ``list`` and ``retrieve`` are - # DRF standard action names (same as ``update`` or ``partial_update``). - if view.detail and view.action in ("list", "retrieve", "superproject"): - # detail view is only allowed on list/retrieve actions (not - # ``update`` or ``partial_update``). - if view.basename != "projects-notifications": - # We don't want to give detail access to projects' - # notifications to users that don't have access to the project. - return True - - if view.basename.startswith("projects"): - project = view._get_parent_project() - if view.has_admin_permission(request.user, project): - return True - - if view.basename.startswith("organizations"): - organization = view._get_parent_organization() - if view.has_admin_permission(request.user, organization): - return True - - if view.basename.startswith("users"): - user = view._get_parent_user() - if view.has_admin_permission(request.user, user): - return True - - return False - - class IsProjectAdmin(BasePermission): """Grant permission if user has admin rights on the Project.""" @@ -108,36 +60,3 @@ def has_permission(self, request, view): organization = view._get_parent_organization() if view.is_admin_member(request.user, organization): return True - - -class UserOrganizationsListing(BasePermission): - def has_permission(self, request, view): - if view.basename == "organizations" and view.action in ( - "list", - None, # needed for BrowsableAPIRenderer - ): - # hitting ``/organizations/``, allowing - return True - - -class CommonPermissionsBase(BasePermission): - - """ - Common permission class used for most APIv3 endpoints. - - This class should be used by ``APIv3Settings.permission_classes`` to define - the permissions for most APIv3 endpoints. It has to be overridden from - corporate to define proper permissions there. - """ - - def has_permission(self, request, view): - if not IsAuthenticated().has_permission(request, view): - return False - - return UserProjectsListing().has_permission( - request, view - ) or PublicDetailPrivateListing().has_permission(request, view) - - -class CommonPermissions(SettingsOverrideObject): - _default_class = CommonPermissionsBase diff --git a/readthedocs/api/v3/tests/responses/projects-builds-list.json b/readthedocs/api/v3/tests/responses/projects-builds-list.json new file mode 100644 index 00000000000..df60e9912fe --- /dev/null +++ b/readthedocs/api/v3/tests/responses/projects-builds-list.json @@ -0,0 +1,33 @@ +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "commit": "a1b2c3", + "created": "2019-04-29T10:00:00Z", + "duration": 60, + "error": "", + "finished": "2019-04-29T10:01:00Z", + "id": 1, + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/builds/1/", + "project": "https://readthedocs.org/api/v3/projects/project/", + "version": "https://readthedocs.org/api/v3/projects/project/versions/v1.0/", + "notifications": "https://readthedocs.org/api/v3/projects/project/builds/1/notifications/" + }, + "urls": { + "build": "https://readthedocs.org/projects/project/builds/1/", + "project": "https://readthedocs.org/projects/project/", + "version": "https://readthedocs.org/dashboard/project/version/v1.0/edit/" + }, + "project": "project", + "state": { + "code": "finished", + "name": "Finished" + }, + "success": true, + "version": "v1.0" + } + ] +} diff --git a/readthedocs/api/v3/tests/test_builds.py b/readthedocs/api/v3/tests/test_builds.py index 359fb1eb6d4..37d451133fb 100644 --- a/readthedocs/api/v3/tests/test_builds.py +++ b/readthedocs/api/v3/tests/test_builds.py @@ -20,23 +20,140 @@ ) @mock.patch("readthedocs.projects.tasks.builds.update_docs_task", mock.MagicMock()) class BuildsEndpointTests(APIEndpointMixin): - def test_projects_builds_list(self): + def test_projects_builds_list_anonymous_user(self): url = reverse( "projects-builds-list", kwargs={ "parent_lookup_project__slug": self.project.slug, }, ) + expected_response = self._get_response_dict("projects-builds-list") + expected_empty_response = self._get_response_dict("projects-list-empty") self.client.logout() + + # Project and version are public. response = self.client.get(url) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Project is private, version is public. + self.project.privacy_level = PRIVATE + self.project.save() + self.version.privacy_level = PUBLIC + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_empty_response) + + # Project and version are private. + self.project.privacy_level = PRIVATE + self.project.save() + self.version.privacy_level = PRIVATE + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_empty_response) + + # Project is public, but version is private. + self.project.privacy_level = PUBLIC + self.project.save() + self.version.privacy_level = PRIVATE + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_empty_response) + + def test_projects_builds_list(self): + url = reverse( + "projects-builds-list", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + }, + ) + expected_response = self._get_response_dict("projects-builds-list") + self.client.logout() self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + + # Project and version are public. + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Project is private, version is public. + self.project.privacy_level = PRIVATE + self.project.save() + self.version.privacy_level = PUBLIC + self.version.save() response = self.client.get(url) self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) - def test_projects_builds_detail(self): + # Project and version are private. + self.project.privacy_level = PRIVATE + self.project.save() + self.version.privacy_level = PRIVATE + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Project is public, but version is private. + self.project.privacy_level = PUBLIC + self.project.save() + self.version.privacy_level = PRIVATE + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + def test_projects_builds_list_other_user(self): + url = reverse( + "projects-builds-list", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + }, + ) + expected_response = self._get_response_dict("projects-builds-list") + expected_empty_response = self._get_response_dict("projects-list-empty") + + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + + # Project and version are public. + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Project is private, version is public. + self.project.privacy_level = PRIVATE + self.project.save() + self.version.privacy_level = PUBLIC + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_empty_response) + + # Project and version are private. + self.project.privacy_level = PRIVATE + self.project.save() + self.version.privacy_level = PRIVATE + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_empty_response) + + # Project is public, but version is private. + self.project.privacy_level = PUBLIC + self.project.save() + self.version.privacy_level = PRIVATE + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_empty_response) + + def test_projects_builds_detail_anoymous_user(self): url = reverse( "projects-builds-detail", kwargs={ @@ -44,19 +161,124 @@ def test_projects_builds_detail(self): "build_pk": self.build.pk, }, ) + expected_response = self._get_response_dict("projects-builds-detail") self.client.logout() + + # Project and version are public. response = self.client.get(url) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Project is private, version is public. + self.project.privacy_level = PRIVATE + self.project.save() + self.version.privacy_level = PUBLIC + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + # Project and version are private. + self.project.privacy_level = PRIVATE + self.project.save() + self.version.privacy_level = PRIVATE + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + # Project is public, but version is private. + self.project.privacy_level = PUBLIC + self.project.save() + self.version.privacy_level = PRIVATE + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_projects_builds_detail(self): + url = reverse( + "projects-builds-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "build_pk": self.build.pk, + }, + ) + expected_response = self._get_response_dict("projects-builds-detail") + self.client.logout() self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + + # Project and version are public. response = self.client.get(url) self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) - self.assertDictEqual( - response.json(), - self._get_response_dict("projects-builds-detail"), + # Project is private, version is public. + self.project.privacy_level = PRIVATE + self.project.save() + self.version.privacy_level = PUBLIC + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Project and version are private. + self.project.privacy_level = PRIVATE + self.project.save() + self.version.privacy_level = PRIVATE + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Project is public, but version is private. + self.project.privacy_level = PUBLIC + self.project.save() + self.version.privacy_level = PRIVATE + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + def test_projects_builds_detail_other_user(self): + url = reverse( + "projects-builds-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "build_pk": self.build.pk, + }, ) + expected_response = self._get_response_dict("projects-builds-detail") + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + + # Project and version are public. + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Project is private, version is public. + self.project.privacy_level = PRIVATE + self.project.save() + self.version.privacy_level = PUBLIC + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + # Project and version are private. + self.project.privacy_level = PRIVATE + self.project.save() + self.version.privacy_level = PRIVATE + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + # Project is public, but version is private. + self.project.privacy_level = PUBLIC + self.project.save() + self.version.privacy_level = PRIVATE + self.version.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 404) def test_projects_versions_builds_list_post(self): url = reverse( @@ -71,6 +293,10 @@ def test_projects_versions_builds_list_post(self): response = self.client.post(url) self.assertEqual(response.status_code, 401) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + response = self.client.post(url) + self.assertEqual(response.status_code, 403) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") self.assertEqual(self.project.builds.count(), 1) response = self.client.post(url) @@ -441,6 +667,10 @@ def test_projects_builds_notifitications_detail_post(self): response = self.client.patch(url, data) self.assertEqual(response.status_code, 401) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 403) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") response = self.client.patch(url, data) self.assertEqual(response.status_code, 204) diff --git a/readthedocs/api/v3/tests/test_projects.py b/readthedocs/api/v3/tests/test_projects.py index 220b26cec08..40cf19bb3e2 100644 --- a/readthedocs/api/v3/tests/test_projects.py +++ b/readthedocs/api/v3/tests/test_projects.py @@ -6,7 +6,7 @@ from django_dynamic_fixture import get from readthedocs.oauth.models import RemoteRepository, RemoteRepositoryRelation -from readthedocs.projects.constants import SINGLE_VERSION_WITHOUT_TRANSLATIONS +from readthedocs.projects.constants import PRIVATE, SINGLE_VERSION_WITHOUT_TRANSLATIONS from readthedocs.projects.models import Project from .mixins import APIEndpointMixin @@ -100,7 +100,7 @@ def test_projects_list_filter_miss(self): self._get_response_dict("projects-list-empty"), ) - def test_own_projects_detail(self): + def test_projects_detail_anonymous_user(self): url = reverse( "projects-detail", kwargs={ @@ -114,38 +114,99 @@ def test_own_projects_detail(self): "active_versions.last_build.config" ), } + expected_response = self._get_response_dict("projects-detail") self.client.logout() + + # The project is public response = self.client.get(url, data) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + # The project is private + Project.objects.filter(slug=self.project.slug).update(privacy_level=PRIVATE) + response = self.client.get(url, data) + self.assertEqual(response.status_code, 404) + + def test_projects_detail(self): + url = reverse( + "projects-detail", + kwargs={ + "project_slug": self.project.slug, + }, + ) + data = { + "expand": ( + "active_versions," + "active_versions.last_build," + "active_versions.last_build.config" + ), + } + expected_response = self._get_response_dict("projects-detail") + + self.client.logout() self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + + # The project is public response = self.client.get(url, data) self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) - self.assertDictEqual( - response.json(), - self._get_response_dict("projects-detail"), + # The project is private + Project.objects.filter(slug=self.project.slug).update(privacy_level=PRIVATE) + response = self.client.get(url, data) + expected_response["privacy_level"] = "private" + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + def test_projects_detail_other_user(self): + url = reverse( + "projects-detail", + kwargs={ + "project_slug": self.project.slug, + }, ) + data = { + "expand": ( + "active_versions," + "active_versions.last_build," + "active_versions.last_build.config" + ), + } + expected_response = self._get_response_dict("projects-detail") + + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + + # The project is public + response = self.client.get(url, data) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # The project is private + self.project.privacy_level = PRIVATE + self.project.save() + response = self.client.get(url, data) + self.assertEqual(response.status_code, 404) @override_settings(ALLOW_PRIVATE_REPOS=True) def test_own_projects_detail_privacy_levels_enabled(self): - self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") - response = self.client.get( - reverse( - "projects-detail", - kwargs={ - "project_slug": self.project.slug, - }, - ), - { - "expand": ( - "active_versions," - "active_versions.last_build," - "active_versions.last_build.config" - ), + url = reverse( + "projects-detail", + kwargs={ + "project_slug": self.project.slug, }, ) + query_params = { + "expand": ( + "active_versions," + "active_versions.last_build," + "active_versions.last_build.config" + ), + } + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.get(url, query_params) self.assertEqual(response.status_code, 200) self.assertDictEqual( response.json(), @@ -155,21 +216,7 @@ def test_own_projects_detail_privacy_levels_enabled(self): self.project.privacy_level = "private" self.project.external_builds_privacy_level = "private" self.project.save() - response = self.client.get( - reverse( - "projects-detail", - kwargs={ - "project_slug": self.project.slug, - }, - ), - { - "expand": ( - "active_versions," - "active_versions.last_build," - "active_versions.last_build.config" - ), - }, - ) + response = self.client.get(url, query_params) self.assertEqual(response.status_code, 200) expected = self._get_response_dict("projects-detail") expected["privacy_level"] = "private" @@ -181,6 +228,28 @@ def test_own_projects_detail_privacy_levels_enabled(self): response.pop("modified") self.assertDictEqual(response, expected) + def test_projects_superproject_anonymous_user(self): + self._create_subproject() + url = reverse( + "projects-superproject", + kwargs={ + "project_slug": self.subproject.slug, + }, + ) + expected_response = self._get_response_dict("projects-superproject") + + self.client.logout() + + # The project is public + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # The project is private + Project.objects.filter(slug=self.project.slug).update(privacy_level=PRIVATE) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + def test_projects_superproject(self): self._create_subproject() @@ -190,19 +259,44 @@ def test_projects_superproject(self): "project_slug": self.subproject.slug, }, ) + expected_response = self._get_response_dict("projects-superproject") self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.get(url) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) - self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + # The project is private + Project.objects.filter(slug=self.project.slug).update(privacy_level=PRIVATE) response = self.client.get(url) + expected_response["privacy_level"] = "private" self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) - self.assertDictEqual( - response.json(), - self._get_response_dict("projects-superproject"), + def test_projects_superproject_other_user(self): + self._create_subproject() + url = reverse( + "projects-superproject", + kwargs={ + "project_slug": self.subproject.slug, + }, ) + expected_response = self._get_response_dict("projects-superproject") + + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # The project is private + self.project.privacy_level = PRIVATE + self.project.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 404) def test_projects_sync_versions(self): # Ensure a default version exists to sync @@ -217,7 +311,7 @@ def test_projects_sync_versions(self): self.client.logout() response = self.client.get(url) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 405) response = self.client.post(url) self.assertEqual(response.status_code, 401) @@ -235,18 +329,6 @@ def test_projects_sync_versions(self): self._get_response_dict("projects-sync-versions"), ) - def test_others_projects_builds_list(self): - self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") - response = self.client.get( - reverse( - "projects-builds-list", - kwargs={ - "parent_lookup_project__slug": self.others_project.slug, - }, - ), - ) - self.assertEqual(response.status_code, 403) - def test_others_projects_detail(self): self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") response = self.client.get( @@ -268,7 +350,7 @@ def test_unauthed_others_projects_detail(self): }, ), ) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) def test_nonexistent_projects_detail(self): self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") diff --git a/readthedocs/api/v3/tests/test_subprojects.py b/readthedocs/api/v3/tests/test_subprojects.py index 687dbf14df7..ab81e6fe5b1 100644 --- a/readthedocs/api/v3/tests/test_subprojects.py +++ b/readthedocs/api/v3/tests/test_subprojects.py @@ -1,6 +1,9 @@ from django.test import override_settings from django.urls import reverse +from readthedocs.projects.constants import PRIVATE +from readthedocs.projects.models import Project + from .mixins import APIEndpointMixin @@ -13,6 +16,27 @@ def setUp(self): super().setUp() self._create_subproject() + def test_projects_subprojects_list_anonymous_user(self): + url = reverse( + "projects-subprojects-list", + kwargs={ + "parent_lookup_parent__slug": self.project.slug, + }, + ) + expected_response = self._get_response_dict("projects-subprojects-list") + empty_response = self._get_response_dict("projects-list-empty") + + # Subproject is public + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Subproject is private + Project.objects.filter(slug=self.subproject.slug).update(privacy_level=PRIVATE) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), empty_response) + def test_projects_subprojects_list(self): url = reverse( "projects-subprojects-list", @@ -20,18 +44,66 @@ def test_projects_subprojects_list(self): "parent_lookup_parent__slug": self.project.slug, }, ) + expected_response = self._get_response_dict("projects-subprojects-list") self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + + # Subproject is public response = self.client.get(url) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) - self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + # Subproject is private + Project.objects.filter(slug=self.subproject.slug).update(privacy_level=PRIVATE) + expected_response["results"][0]["child"]["privacy_level"] = PRIVATE response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertDictEqual( - response.json(), - self._get_response_dict("projects-subprojects-list"), + self.assertDictEqual(response.json(), expected_response) + + def test_projects_subprojects_list_other_user(self): + url = reverse( + "projects-subprojects-list", + kwargs={ + "parent_lookup_parent__slug": self.project.slug, + }, + ) + expected_response = self._get_response_dict("projects-subprojects-list") + empty_response = self._get_response_dict("projects-list-empty") + + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + + # Subproject is public + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Subproject is private + Project.objects.filter(slug=self.subproject.slug).update(privacy_level=PRIVATE) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), empty_response) + + def test_projects_subprojects_detail_anonymous_user(self): + url = reverse( + "projects-subprojects-detail", + kwargs={ + "parent_lookup_parent__slug": self.project.slug, + "alias_slug": self.project_relationship.alias, + }, ) + expected_response = self._get_response_dict("projects-subprojects-detail") + + # Subproject is public + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Subproject is private + Project.objects.filter(slug=self.subproject.slug).update(privacy_level=PRIVATE) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) def test_projects_subprojects_detail(self): url = reverse( @@ -41,18 +113,45 @@ def test_projects_subprojects_detail(self): "alias_slug": self.project_relationship.alias, }, ) + expected_response = self._get_response_dict("projects-subprojects-detail") self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + + # Subproject is public response = self.client.get(url) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) - self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + # Subproject is private + Project.objects.filter(slug=self.subproject.slug).update(privacy_level=PRIVATE) + expected_response["child"]["privacy_level"] = PRIVATE response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertDictEqual( - response.json(), - self._get_response_dict("projects-subprojects-detail"), + self.assertDictEqual(response.json(), expected_response) + + def test_projects_subprojects_detail_other_user(self): + url = reverse( + "projects-subprojects-detail", + kwargs={ + "parent_lookup_parent__slug": self.project.slug, + "alias_slug": self.project_relationship.alias, + }, ) + expected_response = self._get_response_dict("projects-subprojects-detail") + + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + + # Subproject is public + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Subproject is private + Project.objects.filter(slug=self.subproject.slug).update(privacy_level=PRIVATE) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) def test_projects_subprojects_list_post(self): newproject = self._create_new_project() @@ -74,6 +173,10 @@ def test_projects_subprojects_list_post(self): response = self.client.post(url, data) self.assertEqual(response.status_code, 401) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + response = self.client.post(url, data) + self.assertEqual(response.status_code, 403) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") response = self.client.post(url, data) diff --git a/readthedocs/api/v3/tests/test_users.py b/readthedocs/api/v3/tests/test_users.py index dfa42cb0b48..f8a1643db38 100644 --- a/readthedocs/api/v3/tests/test_users.py +++ b/readthedocs/api/v3/tests/test_users.py @@ -131,7 +131,7 @@ def test_users_notifications_detail_other(self): self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") response = self.client.get(url) - self.assertEqual(response.status_code, 404) + self.assertEqual(response.status_code, 403) def test_users_notifications_detail_patch(self): url = reverse( diff --git a/readthedocs/api/v3/tests/test_versions.py b/readthedocs/api/v3/tests/test_versions.py index 31fffa9a322..d8374c2aeb4 100644 --- a/readthedocs/api/v3/tests/test_versions.py +++ b/readthedocs/api/v3/tests/test_versions.py @@ -7,6 +7,7 @@ from readthedocs.builds.constants import EXTERNAL, TAG from readthedocs.builds.models import Version +from readthedocs.projects.constants import PRIVATE from readthedocs.projects.models import HTMLFile, Project from .mixins import APIEndpointMixin @@ -14,7 +15,7 @@ @override_settings(ALLOW_PRIVATE_REPOS=False) class VersionsEndpointTests(APIEndpointMixin): - def test_projects_versions_list(self): + def test_projects_versions_list_anonymous_user(self): url = reverse( "projects-versions-list", kwargs={ @@ -22,11 +23,33 @@ def test_projects_versions_list(self): }, ) - self.client.logout() + # Versions are public response = self.client.get(url) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) + json_data = response.json() + self.assertEqual(len(json_data["results"]), 2) + self.assertEqual(json_data["results"][0]["slug"], "v1.0") + self.assertEqual(json_data["results"][1]["slug"], "latest") + + # Versions are private + self.project.versions.update(privacy_level=PRIVATE) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + json_data = response.json() + self.assertEqual(len(json_data["results"]), 0) + + def test_projects_versions_list(self): + url = reverse( + "projects-versions-list", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + }, + ) + self.client.logout() self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + + # Versions are public response = self.client.get(url) self.assertEqual(response.status_code, 200) response = response.json() @@ -34,19 +57,40 @@ def test_projects_versions_list(self): self.assertEqual(response["results"][0]["slug"], "v1.0") self.assertEqual(response["results"][1]["slug"], "latest") - def test_others_projects_versions_list(self): - self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") - response = self.client.get( - reverse( - "projects-versions-list", - kwargs={ - "parent_lookup_project__slug": self.others_project.slug, - }, - ), + # Versions are private + Project.objects.filter(slug=self.project.slug).update(privacy_level=PRIVATE) + response = self.client.get(url) + response = response.json() + self.assertEqual(len(response["results"]), 2) + self.assertEqual(response["results"][0]["slug"], "v1.0") + self.assertEqual(response["results"][1]["slug"], "latest") + + def test_projects_versions_list_other_user(self): + url = reverse( + "projects-versions-list", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + }, ) - self.assertEqual(response.status_code, 403) + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") - def test_projects_versions_detail(self): + # Versions are public + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + json_data = response.json() + self.assertEqual(len(json_data["results"]), 2) + self.assertEqual(json_data["results"][0]["slug"], "v1.0") + self.assertEqual(json_data["results"][1]["slug"], "latest") + + # Versions are private + self.project.versions.update(privacy_level=PRIVATE) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + json_data = response.json() + self.assertEqual(len(json_data["results"]), 0) + + def test_projects_versions_detail_anonymous_user(self): url = reverse( "projects-versions-detail", kwargs={ @@ -54,18 +98,67 @@ def test_projects_versions_detail(self): "version_slug": "v1.0", }, ) + expected_response = self._get_response_dict("projects-versions-detail") self.client.logout() + + # Version is public response = self.client.get(url) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + # Version is private + self.project.versions.update(privacy_level=PRIVATE) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_projects_versions_detail(self): + url = reverse( + "projects-versions-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "version_slug": "v1.0", + }, + ) + expected_response = self._get_response_dict("projects-versions-detail") + + self.client.logout() self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + + # Version is public response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertDictEqual( - response.json(), - self._get_response_dict("projects-versions-detail"), + self.assertDictEqual(response.json(), expected_response) + + # Version is private + self.project.versions.update(privacy_level=PRIVATE) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + expected_response["privacy_level"] = "private" + self.assertDictEqual(response.json(), expected_response) + + def test_projects_versions_detail_other_user(self): + url = reverse( + "projects-versions-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "version_slug": "v1.0", + }, ) + expected_response = self._get_response_dict("projects-versions-detail") + + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + + # Version is public + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.json(), expected_response) + + # Version is private + self.project.versions.update(privacy_level=PRIVATE) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) @override_settings(ALLOW_PRIVATE_REPOS=True) def test_projects_versions_detail_privacy_levels_allowed(self): @@ -171,6 +264,10 @@ def test_projects_versions_partial_update(self): response = self.client.patch(url, data) self.assertEqual(response.status_code, 401) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}") + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 403) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") response = self.client.patch(url, data) self.assertEqual(response.status_code, 204) diff --git a/readthedocs/api/v3/views.py b/readthedocs/api/v3/views.py index c0b88fdc861..becd3a67fdb 100644 --- a/readthedocs/api/v3/views.py +++ b/readthedocs/api/v3/views.py @@ -2,7 +2,6 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Exists, OuterRef -from django.shortcuts import get_object_or_404 from rest_flex_fields import is_expanded from rest_flex_fields.views import FlexFieldsMixin from rest_framework import status @@ -25,6 +24,7 @@ from rest_framework_extensions.mixins import NestedViewSetMixin from readthedocs.api.v2.permissions import ReadOnlyPermission +from readthedocs.builds.constants import EXTERNAL from readthedocs.builds.models import Build, Version from readthedocs.core.utils import trigger_build from readthedocs.core.utils.extend import SettingsOverrideObject @@ -61,7 +61,7 @@ UserQuerySetMixin, ) from .permissions import ( - CommonPermissions, + IsCurrentUser, IsOrganizationAdmin, IsOrganizationAdminMember, IsProjectAdmin, @@ -104,7 +104,6 @@ class APIv3Settings: """ authentication_classes = (TokenAuthentication, SessionAuthentication) - permission_classes = (CommonPermissions,) pagination_class = LimitOffsetPagination LimitOffsetPagination.default_limit = 10 @@ -131,7 +130,6 @@ class ProjectsViewSetBase( lookup_field = "slug" lookup_url_kwarg = "project_slug" filterset_class = ProjectFilter - queryset = Project.objects.all() permit_list_expands = [ "active_versions", "active_versions.last_build", @@ -140,8 +138,22 @@ class ProjectsViewSetBase( "teams", ] + def get_permissions(self): + # Create and list are actions that act on the current user. + if self.action in ("create", "list"): + permission_classes = [IsAuthenticated] + # Actions that change the state of the project require admin permissions on the project. + elif self.action in ("update", "partial_update", "destroy", "sync_versions"): + permission_classes = [IsAuthenticated & IsProjectAdmin] + # Any other action is read-only. + else: + permission_classes = [ReadOnlyPermission] + return [permission() for permission in permission_classes] + def get_view_name(self): # Avoid "Base" in BrowseableAPI view's title + if self.name: + return self.name return f"Projects {self.suffix}" def get_serializer_class(self): @@ -151,9 +163,7 @@ def get_serializer_class(self): For GET it returns a serializer with many fields and on PUT/PATCH/POST, it return a serializer to validate just a few fields. """ - if self.action in ("list", "retrieve", "superproject"): - # NOTE: ``superproject`` is the @action defined in the - # ProjectViewSet that returns the superproject of a project. + if self.action in ("list", "retrieve"): return ProjectSerializer if self.action == "create": @@ -166,16 +176,15 @@ def get_serializer_class(self): return ProjectSerializer def get_queryset(self): - # Allow hitting ``/api/v3/projects/`` to list their own projects - if self.basename == "projects" and self.action == "list": - # We force returning ``Project`` objects here because it's under the - # ``projects`` view. - return self.admin_projects(self.request.user) + if self.action == "list": + # When listing, return all the projects where the user is admin. + queryset = self.admin_projects(self.request.user) + else: + queryset = super().get_queryset() # This could be a class attribute and managed on the ``ProjectQuerySetMixin`` in # case we want to extend the ``prefetch_related`` to other views as # well. - queryset = super().get_queryset() return queryset.prefetch_related( "related_projects", "domains", @@ -187,14 +196,16 @@ def create(self, request, *args, **kwargs): """ Import Project. - Override to use a different serializer in the response. + Override to use a different serializer in the response, + since it's a different format than the one used for the request. """ serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) headers = self.get_success_headers(serializer.data) - # Use serializer that fully render a Project + # Use a serializer that fully renders a Project, + # instead of the one used for the request. serializer = ProjectSerializer(instance=serializer.instance) return Response( @@ -214,13 +225,16 @@ def perform_create(self, serializer): @action(detail=True, methods=["get"]) def superproject(self, request, project_slug): """Return the superproject of a ``Project``.""" - project = self.get_object() - try: - superproject = project.superprojects.first().parent - data = self.get_serializer(superproject).data - return Response(data) - except Exception: + superproject = self._get_superproject() + if not superproject: return Response(status=status.HTTP_404_NOT_FOUND) + data = ProjectSerializer(superproject).data + return Response(data) + + def _get_superproject(self): + """Get the superproject of the project, taking into consideration the user permissions.""" + project = self.get_object() + return self.get_queryset().filter(subprojects__child=project).first() @action(detail=True, methods=["post"], url_path="sync-versions") def sync_versions(self, request, project_slug): @@ -263,7 +277,7 @@ class SubprojectRelationshipViewSet( model = ProjectRelationship lookup_field = "alias" lookup_url_kwarg = "alias_slug" - queryset = ProjectRelationship.objects.all() + permission_classes = [ReadOnlyPermission | (IsAuthenticated & IsProjectAdmin)] def get_serializer_class(self): """ @@ -316,7 +330,7 @@ class TranslationRelationshipViewSet( lookup_field = "slug" lookup_url_kwarg = "project_slug" serializer_class = ProjectSerializer - queryset = Project.objects.all() + permission_classes = [ReadOnlyPermission | (IsAuthenticated & IsProjectAdmin)] # Inherit order is important here. ``NestedViewSetMixin`` has to be on the left @@ -339,7 +353,7 @@ class VersionsViewSet( lookup_value_regex = r"[^/]+" filterset_class = VersionFilter - queryset = Version.internal.all() + permission_classes = [ReadOnlyPermission | (IsAuthenticated & IsProjectAdmin)] permit_list_expands = [ "last_build", "last_build.config", @@ -367,6 +381,10 @@ def update(self, request, *args, **kwargs): version.post_save(was_active=was_active) return result + def get_queryset(self): + """Overridden to allow internal versions only.""" + return super().get_queryset().exclude(type=EXTERNAL) + class BuildsViewSet( APIv3Settings, @@ -380,7 +398,7 @@ class BuildsViewSet( lookup_url_kwarg = "build_pk" serializer_class = BuildSerializer filterset_class = BuildFilter - queryset = Build.internal.all() + permission_classes = [ReadOnlyPermission | (IsAuthenticated & IsProjectAdmin)] permit_list_expands = [ "config", ] @@ -451,7 +469,6 @@ class NotificationsForUserViewSet( model = Notification serializer_class = NotificationSerializer - queryset = Notification.objects.all() # Override global permissions here because it doesn't not make sense to hit # this endpoint without being logged in. We can't use our @@ -479,8 +496,9 @@ class NotificationsProjectViewSet( lookup_field = "pk" lookup_url_kwarg = "notification_pk" serializer_class = NotificationSerializer - queryset = Notification.objects.all() filterset_class = NotificationFilter + # We don't want to show notifications to users that don't have admin access to the project. + permission_classes = [IsAuthenticated & IsProjectAdmin] def get_queryset(self): project = self._get_parent_project() @@ -502,26 +520,11 @@ class NotificationsBuildViewSet( lookup_field = "pk" lookup_url_kwarg = "notification_pk" serializer_class = NotificationSerializer - queryset = Notification.objects.all() filterset_class = NotificationFilter # We need to show build notifications to anonymous users # on public builds (the queryset will filter them out). # We allow project admins to edit notifications. - permission_classes = [ReadOnlyPermission | IsProjectAdmin] - - def _get_parent_build(self): - """ - Overriden to filter by builds the current user has access to. - - This includes public builds from other projects. - """ - build_pk = self._get_parent_object_lookup(self.BUILD_LOOKUP_NAMES) - project_slug = self._get_parent_object_lookup(self.PROJECT_LOOKUP_NAMES) - return get_object_or_404( - Build.objects.api(user=self.request.user), - pk=build_pk, - project__slug=project_slug, - ) + permission_classes = [ReadOnlyPermission | (IsAuthenticated & IsProjectAdmin)] def get_queryset(self): build = self._get_parent_build() @@ -538,7 +541,6 @@ class RedirectsViewSet( model = Redirect lookup_field = "pk" lookup_url_kwarg = "redirect_pk" - queryset = Redirect.objects.all() permission_classes = (IsAuthenticated & IsProjectAdmin,) def get_queryset(self): @@ -572,7 +574,6 @@ class EnvironmentVariablesViewSet( model = EnvironmentVariable lookup_field = "pk" lookup_url_kwarg = "environmentvariable_pk" - queryset = EnvironmentVariable.objects.all() serializer_class = EnvironmentVariableSerializer permission_classes = (IsAuthenticated & IsProjectAdmin,) @@ -596,7 +597,6 @@ class RemoteRepositoryViewSet( model = RemoteRepository serializer_class = RemoteRepositorySerializer filterset_class = RemoteRepositoryFilter - queryset = RemoteRepository.objects.all() permission_classes = (IsAuthenticated,) permit_list_expands = ["remote_organization", "projects"] @@ -605,6 +605,7 @@ def get_queryset(self): super() .get_queryset() .annotate( + # This field will be used by the serializer. _admin=Exists( RemoteRepositoryRelation.objects.filter( remote_repository=OuterRef("pk"), @@ -630,7 +631,6 @@ class RemoteOrganizationViewSet( model = RemoteOrganization serializer_class = RemoteOrganizationSerializer filterset_class = RemoteOrganizationFilter - queryset = RemoteOrganization.objects.all() permission_classes = (IsAuthenticated,) @@ -667,14 +667,14 @@ class NotificationsUserViewSet( lookup_field = "pk" lookup_url_kwarg = "notification_pk" serializer_class = NotificationSerializer - queryset = Notification.objects.all() filterset_class = NotificationFilter + permission_classes = [IsAuthenticated & IsCurrentUser] def get_queryset(self): # Filter the queryset by only notifications attached to the particular user # that's making the request to this endpoint content_type = ContentType.objects.get_for_model(User) - return self.queryset.filter( + return Notification.objects.filter( attached_to_content_type=content_type, attached_to_id=self.request.user.pk, ) @@ -684,6 +684,7 @@ class OrganizationsViewSetBase( APIv3Settings, GenericViewSet, ): + # TODO: migrate code from corporate here. # NOTE: this viewset is only useful for nested URLs required for notifications: # /api/v3/organizations//notifications/ # However, accessing to /api/v3/organizations/ or /api/v3/organizations// will return 404. @@ -705,18 +706,18 @@ class OrganizationsProjectsViewSet( NestedViewSetMixin, OrganizationQuerySetMixin, FlexFieldsMixin, - ReadOnlyModelViewSet, + ListModelMixin, + GenericViewSet, ): model = Project - lookup_field = "slug" - lookup_url_kwarg = "project_slug" - queryset = Project.objects.all() serializer_class = ProjectSerializer permission_classes = [IsAuthenticated & IsOrganizationAdminMember] # We don't need to expand the organization, it's already known. permit_list_expands = [] def get_view_name(self): + if self.name: + return self.name return f"Organizations Projects {self.suffix}" @@ -734,7 +735,6 @@ class NotificationsOrganizationViewSet( lookup_field = "pk" lookup_url_kwarg = "notification_pk" serializer_class = NotificationSerializer - queryset = Notification.objects.all() filterset_class = NotificationFilter permission_classes = [IsAuthenticated & IsOrganizationAdmin] diff --git a/readthedocs/organizations/querysets.py b/readthedocs/organizations/querysets.py index ed0016c597f..d7801de0937 100644 --- a/readthedocs/organizations/querysets.py +++ b/readthedocs/organizations/querysets.py @@ -26,6 +26,9 @@ def for_user(self, user): def for_admin_user(self, user): return self.filter(owners__in=[user]).distinct() + def api(self, user): + return self.for_user(user) + def created_days_ago(self, days, field="pub_date"): """ Filter organizations by creation date.