Skip to content

Commit 75e155f

Browse files
authored
API V3: Allow other users to see build notifications from public projects (#11449)
* API V3: Allow other users to see build notifications from public projects Fixes #11333 * Fix tests * Fix * Update docstring
1 parent 5366336 commit 75e155f

File tree

3 files changed

+237
-23
lines changed

3 files changed

+237
-23
lines changed

readthedocs/api/v3/permissions.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,9 @@ def has_permission(self, request, view):
6363
if view.detail and view.action in ("list", "retrieve", "superproject"):
6464
# detail view is only allowed on list/retrieve actions (not
6565
# ``update`` or ``partial_update``).
66-
if view.basename not in (
67-
"projects-notifications",
68-
"projects-builds-notifications",
69-
):
70-
# We don't want to give detail access to resources'
71-
# notifications to users that don't have access to those
72-
# resources.
66+
if view.basename != "projects-notifications":
67+
# We don't want to give detail access to projects'
68+
# notifications to users that don't have access to the project.
7369
return True
7470

7571
if view.basename.startswith("projects"):

readthedocs/api/v3/tests/test_builds.py

Lines changed: 214 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.urls import reverse
55

66
from readthedocs.builds.constants import EXTERNAL
7+
from readthedocs.projects.constants import PRIVATE, PUBLIC
78
from readthedocs.subscriptions.constants import TYPE_CONCURRENT_BUILDS
89
from readthedocs.subscriptions.products import RTDProductFeature
910

@@ -114,27 +115,96 @@ def test_external_version_projects_versions_builds_list_post(self):
114115
expected["version"]["urls"]["vcs"] = "https://github.com/rtfd/project/pull/v1.0"
115116
self.assertDictEqual(response_json, expected)
116117

117-
def test_projects_builds_notifications_list(self):
118+
def test_projects_builds_notifications_list_anonymous_user(self):
118119
url = reverse(
119120
"projects-builds-notifications-list",
120121
kwargs={
121122
"parent_lookup_project__slug": self.project.slug,
122123
"parent_lookup_build__id": self.build.pk,
123124
},
124125
)
126+
expected_response = self._get_response_dict(
127+
"projects-builds-notifications-list"
128+
)
125129

126130
self.client.logout()
131+
132+
# Project and version are public.
127133
response = self.client.get(url)
128-
self.assertEqual(response.status_code, 401)
134+
self.assertEqual(response.status_code, 200)
135+
self.assertDictEqual(response.json(), expected_response)
136+
137+
# Project is private, version is public.
138+
self.project.privacy_level = PRIVATE
139+
self.project.save()
140+
self.version.privacy_level = PUBLIC
141+
self.version.save()
142+
response = self.client.get(url)
143+
self.assertEqual(response.status_code, 404)
144+
145+
# Project and version are private.
146+
self.project.privacy_level = PRIVATE
147+
self.project.save()
148+
self.version.privacy_level = PRIVATE
149+
self.version.save()
150+
response = self.client.get(url)
151+
self.assertEqual(response.status_code, 404)
152+
153+
# Project is public, but version is private.
154+
self.project.privacy_level = PUBLIC
155+
self.project.save()
156+
self.version.privacy_level = PRIVATE
157+
self.version.save()
158+
response = self.client.get(url)
159+
self.assertEqual(response.status_code, 404)
160+
161+
def test_projects_builds_notifications_list(self):
162+
url = reverse(
163+
"projects-builds-notifications-list",
164+
kwargs={
165+
"parent_lookup_project__slug": self.project.slug,
166+
"parent_lookup_build__id": self.build.pk,
167+
},
168+
)
169+
expected_response = self._get_response_dict(
170+
"projects-builds-notifications-list"
171+
)
172+
173+
self.client.logout()
129174

130175
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
131176
response = self.client.get(url)
177+
178+
# Project and version are public.
132179
self.assertEqual(response.status_code, 200)
180+
self.assertDictEqual(response.json(), expected_response)
133181

134-
self.assertDictEqual(
135-
response.json(),
136-
self._get_response_dict("projects-builds-notifications-list"),
137-
)
182+
# Project is private, version is public.
183+
self.project.privacy_level = PRIVATE
184+
self.project.save()
185+
self.version.privacy_level = PUBLIC
186+
self.version.save()
187+
response = self.client.get(url)
188+
self.assertEqual(response.status_code, 200)
189+
self.assertDictEqual(response.json(), expected_response)
190+
191+
# Project and version are private.
192+
self.project.privacy_level = PRIVATE
193+
self.project.save()
194+
self.version.privacy_level = PRIVATE
195+
self.version.save()
196+
response = self.client.get(url)
197+
self.assertEqual(response.status_code, 200)
198+
self.assertDictEqual(response.json(), expected_response)
199+
200+
# Project is public, but version is private.
201+
self.project.privacy_level = PUBLIC
202+
self.project.save()
203+
self.version.privacy_level = PRIVATE
204+
self.version.save()
205+
response = self.client.get(url)
206+
self.assertEqual(response.status_code, 200)
207+
self.assertDictEqual(response.json(), expected_response)
138208

139209
def test_projects_builds_notifications_list_other_user(self):
140210
url = reverse(
@@ -144,10 +214,40 @@ def test_projects_builds_notifications_list_other_user(self):
144214
"parent_lookup_build__id": self.build.pk,
145215
},
146216
)
147-
217+
expected_response = self._get_response_dict(
218+
"projects-builds-notifications-list"
219+
)
220+
self.client.logout()
148221
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}")
222+
223+
# Project and version are public.
224+
response = self.client.get(url)
225+
self.assertEqual(response.status_code, 200)
226+
self.assertDictEqual(response.json(), expected_response)
227+
228+
# Project is private, version is public.
229+
self.project.privacy_level = PRIVATE
230+
self.project.save()
231+
self.version.privacy_level = PUBLIC
232+
self.version.save()
149233
response = self.client.get(url)
150-
self.assertEqual(response.status_code, 403)
234+
self.assertEqual(response.status_code, 404)
235+
236+
# Project and version are private.
237+
self.project.privacy_level = PRIVATE
238+
self.project.save()
239+
self.version.privacy_level = PRIVATE
240+
self.version.save()
241+
response = self.client.get(url)
242+
self.assertEqual(response.status_code, 404)
243+
244+
# Project is public, but version is private.
245+
self.project.privacy_level = PUBLIC
246+
self.project.save()
247+
self.version.privacy_level = PRIVATE
248+
self.version.save()
249+
response = self.client.get(url)
250+
self.assertEqual(response.status_code, 404)
151251

152252
# User can see their own notifications.
153253
url = reverse(
@@ -191,6 +291,49 @@ def test_projects_builds_notifications_list_post(self):
191291
# We don't allow POST on this endpoint
192292
self.assertEqual(response.status_code, 405)
193293

294+
def test_projects_builds_notifitications_detail_anonymous_user(self):
295+
url = reverse(
296+
"projects-builds-notifications-detail",
297+
kwargs={
298+
"parent_lookup_project__slug": self.project.slug,
299+
"parent_lookup_build__id": self.build.pk,
300+
"notification_pk": self.notification_build.pk,
301+
},
302+
)
303+
expected_response = self._get_response_dict(
304+
"projects-builds-notifications-detail"
305+
)
306+
self.client.logout()
307+
308+
# Project and version are public.
309+
response = self.client.get(url)
310+
self.assertEqual(response.status_code, 200)
311+
self.assertDictEqual(response.json(), expected_response)
312+
313+
# Project is private, version is public.
314+
self.project.privacy_level = PRIVATE
315+
self.project.save()
316+
self.version.privacy_level = PUBLIC
317+
self.version.save()
318+
response = self.client.get(url)
319+
self.assertEqual(response.status_code, 404)
320+
321+
# Project and version are private.
322+
self.project.privacy_level = PRIVATE
323+
self.project.save()
324+
self.version.privacy_level = PRIVATE
325+
self.version.save()
326+
response = self.client.get(url)
327+
self.assertEqual(response.status_code, 404)
328+
329+
# Project is public, but version is private.
330+
self.project.privacy_level = PUBLIC
331+
self.project.save()
332+
self.version.privacy_level = PRIVATE
333+
self.version.save()
334+
response = self.client.get(url)
335+
self.assertEqual(response.status_code, 404)
336+
194337
def test_projects_builds_notifitications_detail(self):
195338
url = reverse(
196339
"projects-builds-notifications-detail",
@@ -200,19 +343,44 @@ def test_projects_builds_notifitications_detail(self):
200343
"notification_pk": self.notification_build.pk,
201344
},
202345
)
346+
expected_response = self._get_response_dict(
347+
"projects-builds-notifications-detail"
348+
)
203349

204350
self.client.logout()
351+
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
352+
353+
# Project and version are public.
205354
response = self.client.get(url)
206-
self.assertEqual(response.status_code, 401)
355+
self.assertEqual(response.status_code, 200)
356+
self.assertDictEqual(response.json(), expected_response)
207357

208-
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
358+
# Project is private, version is public.
359+
self.project.privacy_level = PRIVATE
360+
self.project.save()
361+
self.version.privacy_level = PUBLIC
362+
self.version.save()
209363
response = self.client.get(url)
210364
self.assertEqual(response.status_code, 200)
365+
self.assertDictEqual(response.json(), expected_response)
211366

212-
self.assertDictEqual(
213-
response.json(),
214-
self._get_response_dict("projects-builds-notifications-detail"),
215-
)
367+
# Project and version are private.
368+
self.project.privacy_level = PRIVATE
369+
self.project.save()
370+
self.version.privacy_level = PRIVATE
371+
self.version.save()
372+
response = self.client.get(url)
373+
self.assertEqual(response.status_code, 200)
374+
self.assertDictEqual(response.json(), expected_response)
375+
376+
# Project is public, but version is private.
377+
self.project.privacy_level = PUBLIC
378+
self.project.save()
379+
self.version.privacy_level = PRIVATE
380+
self.version.save()
381+
response = self.client.get(url)
382+
self.assertEqual(response.status_code, 200)
383+
self.assertDictEqual(response.json(), expected_response)
216384

217385
def test_projects_builds_notifitications_detail_other_user(self):
218386
url = reverse(
@@ -223,10 +391,40 @@ def test_projects_builds_notifitications_detail_other_user(self):
223391
"notification_pk": self.notification_build.pk,
224392
},
225393
)
226-
394+
expected_response = self._get_response_dict(
395+
"projects-builds-notifications-detail"
396+
)
397+
self.client.logout()
227398
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.others_token.key}")
399+
400+
# Project and version are public.
401+
response = self.client.get(url)
402+
self.assertEqual(response.status_code, 200)
403+
self.assertDictEqual(response.json(), expected_response)
404+
405+
# Project is private, version is public.
406+
self.project.privacy_level = PRIVATE
407+
self.project.save()
408+
self.version.privacy_level = PUBLIC
409+
self.version.save()
228410
response = self.client.get(url)
229-
self.assertEqual(response.status_code, 403)
411+
self.assertEqual(response.status_code, 404)
412+
413+
# Project and version are private.
414+
self.project.privacy_level = PRIVATE
415+
self.project.save()
416+
self.version.privacy_level = PRIVATE
417+
self.version.save()
418+
response = self.client.get(url)
419+
self.assertEqual(response.status_code, 404)
420+
421+
# Project is public, but version is private.
422+
self.project.privacy_level = PUBLIC
423+
self.project.save()
424+
self.version.privacy_level = PRIVATE
425+
self.version.save()
426+
response = self.client.get(url)
427+
self.assertEqual(response.status_code, 404)
230428

231429
def test_projects_builds_notifitications_detail_post(self):
232430
url = reverse(

readthedocs/api/v3/views.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.contrib.auth.models import User
33
from django.contrib.contenttypes.models import ContentType
44
from django.db.models import Exists, OuterRef
5+
from django.shortcuts import get_object_or_404
56
from rest_flex_fields import is_expanded
67
from rest_flex_fields.views import FlexFieldsMixin
78
from rest_framework import status
@@ -23,6 +24,7 @@
2324
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet
2425
from rest_framework_extensions.mixins import NestedViewSetMixin
2526

27+
from readthedocs.api.v2.permissions import ReadOnlyPermission
2628
from readthedocs.builds.models import Build, Version
2729
from readthedocs.core.utils import trigger_build
2830
from readthedocs.core.utils.extend import SettingsOverrideObject
@@ -477,6 +479,24 @@ class NotificationsBuildViewSet(
477479
serializer_class = NotificationSerializer
478480
queryset = Notification.objects.all()
479481
filterset_class = NotificationFilter
482+
# We need to show build notifications to anonymous users
483+
# on public builds (the queryset will filter them out).
484+
# We allow project admins to edit notifications.
485+
permission_classes = [ReadOnlyPermission | IsProjectAdmin]
486+
487+
def _get_parent_build(self):
488+
"""
489+
Overriden to filter by builds the current user has access to.
490+
491+
This includes public builds from other projects.
492+
"""
493+
build_pk = self._get_parent_object_lookup(self.BUILD_LOOKUP_NAMES)
494+
project_slug = self._get_parent_object_lookup(self.PROJECT_LOOKUP_NAMES)
495+
return get_object_or_404(
496+
Build.objects.api(user=self.request.user),
497+
pk=build_pk,
498+
project__slug=project_slug,
499+
)
480500

481501
def get_queryset(self):
482502
build = self._get_parent_build()

0 commit comments

Comments
 (0)