Skip to content

Commit bf12238

Browse files
committed
add collected in and node collections endpoints with tests
1 parent b108fce commit bf12238

File tree

10 files changed

+184
-3
lines changed

10 files changed

+184
-3
lines changed

api/nodes/permissions.py

+13
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,19 @@ def has_object_permission(self, request, view, obj):
183183
return request.method in permissions.SAFE_METHODS
184184
return True
185185

186+
187+
class ReadOnlyIfPublicOrContributor(permissions.BasePermission):
188+
def has_object_permission(self, request, view, obj):
189+
user_auth = get_user_auth(request)
190+
node = view.get_node(check_object_permissions=False) # recursion if true
191+
192+
# Allow if public
193+
if node.is_public:
194+
return True
195+
196+
# Allow if the user has read permission
197+
return node.has_permission(user_auth.user, 'read')
198+
186199
class ContributorDetailPermissions(permissions.BasePermission):
187200
"""Permissions for contributor detail page."""
188201

api/nodes/serializers.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from django.core.exceptions import ValidationError
2929
from framework.auth.core import Auth
3030
from framework.exceptions import PermissionsError
31-
from osf.models import Tag
31+
from osf.models import Tag, CollectionSubmission
3232
from rest_framework import serializers as ser
3333
from rest_framework import exceptions
3434
from addons.base.exceptions import InvalidAuthError, InvalidFolderError
@@ -324,6 +324,12 @@ class NodeSerializer(TaxonomizableSerializerMixin, JSONAPISerializer):
324324
related_meta={'count': 'get_node_count'},
325325
)
326326

327+
collected_in = RelationshipField(
328+
related_view='nodes:node-collections',
329+
related_view_kwargs={'node_id': '<_id>'},
330+
related_meta={'count': 'get_collection_count'},
331+
)
332+
327333
comments = RelationshipField(
328334
related_view='nodes:node-comments',
329335
related_view_kwargs={'node_id': '<_id>'},
@@ -620,6 +626,9 @@ def get_absolute_url(self, obj):
620626
def get_logs_count(self, obj):
621627
return obj.logs.count()
622628

629+
def get_collection_count(self, obj):
630+
return CollectionSubmission.objects.filter(guid___id=obj._id).count()
631+
623632
def get_node_count(self, obj):
624633
"""
625634
Returns the count of a node's direct children that the user has permission to view.

api/nodes/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
re_path(r'^(?P<node_id>\w+)/citation/$', views.NodeCitationDetail.as_view(), name=views.NodeCitationDetail.view_name),
2020
re_path(r'^(?P<node_id>\w+)/citation/(?P<style_id>[-\w]+)/$', views.NodeCitationStyleDetail.as_view(), name=views.NodeCitationStyleDetail.view_name),
2121
re_path(r'^(?P<node_id>\w+)/comments/$', views.NodeCommentsList.as_view(), name=views.NodeCommentsList.view_name),
22+
re_path(r'^(?P<node_id>\w+)/collections/$', views.NodeCollectionsList.as_view(), name=views.NodeCollectionsList.view_name),
2223
re_path(r'^(?P<node_id>\w+)/contributors_and_group_members/$', views.NodeContributorsAndGroupMembersList.as_view(), name=views.NodeContributorsAndGroupMembersList.view_name),
2324
re_path(r'^(?P<node_id>\w+)/implicit_contributors/$', views.NodeImplicitContributorsList.as_view(), name=views.NodeImplicitContributorsList.view_name),
2425
re_path(r'^(?P<node_id>\w+)/contributors/$', views.NodeContributorsList.as_view(), name=views.NodeContributorsList.view_name),

api/nodes/views.py

+25-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import dataclasses
66
import waffle
7+
8+
from api.collections.serializers import CollectionSerializer
79
from osf import features
810
from packaging.version import Version
911
from django.apps import apps
@@ -96,6 +98,7 @@
9698
ExcludeWithdrawals,
9799
NodeLinksShowIfVersion,
98100
ReadOnlyIfWithdrawn,
101+
ReadOnlyIfPublicOrContributor,
99102
)
100103
from api.nodes.serializers import (
101104
NodeSerializer,
@@ -158,7 +161,7 @@
158161
File,
159162
Folder,
160163
CedarMetadataRecord,
161-
Preprint,
164+
Preprint, Collection,
162165
)
163166
from addons.osfstorage.models import Region
164167
from osf.utils.permissions import ADMIN, WRITE_NODE
@@ -1674,6 +1677,27 @@ def perform_create(self, serializer):
16741677
serializer.save()
16751678

16761679

1680+
class NodeCollectionsList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin, NodeMixin):
1681+
permission_classes = (
1682+
drf_permissions.IsAuthenticatedOrReadOnly,
1683+
base_permissions.TokenHasScope,
1684+
ReadOnlyIfPublicOrContributor,
1685+
)
1686+
1687+
required_read_scopes = [CoreScopes.NODE_COLLECTIONS_READ]
1688+
required_write_scopes = [CoreScopes.NODE_COLLECTIONS_WRITE]
1689+
1690+
serializer_class = CollectionSerializer
1691+
view_category = 'nodes'
1692+
view_name = 'node-collections'
1693+
1694+
def get_default_queryset(self):
1695+
return Collection.objects.filter(guid_links___id=self.get_node()._id)
1696+
1697+
def get_queryset(self):
1698+
return self.get_queryset_from_request()
1699+
1700+
16771701
class NodeInstitutionsList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin, NodeMixin):
16781702
"""The documentation for this endpoint can be found [here](https://developer.osf.io/#operation/nodes_institutions_list).
16791703
"""

api_tests/base/test_serializers.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,17 @@ def test_registration_serializer(self):
196196
'cedar_metadata_records',
197197
]
198198
# fields that do not appear on registrations
199-
non_registration_fields = ['registrations', 'draft_registrations', 'templated_by_count', 'settings', 'storage', 'children', 'groups', 'subjects_acceptable']
199+
non_registration_fields = [
200+
'registrations',
201+
'draft_registrations',
202+
'templated_by_count',
203+
'settings',
204+
'storage',
205+
'children',
206+
'groups',
207+
'subjects_acceptable',
208+
'collected_in'
209+
]
200210

201211
for field in NodeSerializer._declared_fields:
202212
assert field in RegistrationSerializer._declared_fields

api_tests/nodes/serializers/test_serializers.py

+1
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ def test_serialization(self):
158158
'groups',
159159
'original_response',
160160
'latest_response',
161+
'collected_in',
161162
]
162163

163164
# Attributes
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import pytest
2+
3+
from osf_tests.factories import (
4+
ProjectFactory,
5+
CollectionFactory,
6+
AuthUserFactory
7+
)
8+
from api.base.settings.defaults import API_BASE
9+
10+
11+
@pytest.mark.django_db
12+
class TestNodeCollectionsList:
13+
@pytest.fixture()
14+
def user(self):
15+
return AuthUserFactory()
16+
17+
@pytest.fixture()
18+
def user_two(self):
19+
return AuthUserFactory()
20+
21+
@pytest.fixture()
22+
def public_project(self, user):
23+
return ProjectFactory(is_public=True, creator=user)
24+
25+
@pytest.fixture()
26+
def private_project(self, user):
27+
return ProjectFactory(is_public=False, creator=user)
28+
29+
@pytest.fixture()
30+
def collection(self, user):
31+
return CollectionFactory(creator=user)
32+
33+
def test_public_node_collections_list_logged_out(self, app, public_project, collection):
34+
collection.collect_object(public_project, collector=collection.creator)
35+
res = app.get(f'/{API_BASE}nodes/{public_project._id}/collections/')
36+
assert res.status_code == 200
37+
assert len(res.json['data']) == 1
38+
assert res.json['data'][0]['id'] == collection._id
39+
40+
def test_public_node_collections_list_logged_in(self, app, user_two, public_project, collection):
41+
collection.collect_object(public_project, collector=collection.creator)
42+
res = app.get(f'/{API_BASE}nodes/{public_project._id}/collections/', auth=user_two.auth)
43+
assert res.status_code == 200
44+
assert len(res.json['data']) == 1
45+
assert res.json['data'][0]['id'] == collection._id
46+
47+
def test_private_node_collections_list_admin(self, app, user, private_project, collection):
48+
collection.collect_object(private_project, collector=user)
49+
res = app.get(f'/{API_BASE}nodes/{private_project._id}/collections/', auth=user.auth)
50+
assert res.status_code == 200
51+
assert len(res.json['data']) == 1
52+
assert res.json['data'][0]['id'] == collection._id
53+
54+
def test_private_node_collections_list_non_contrib(self, app, user_two, private_project, collection):
55+
collection.collect_object(private_project, collector=collection.creator)
56+
res = app.get(
57+
f'/{API_BASE}nodes/{private_project._id}/collections/',
58+
auth=user_two.auth,
59+
expect_errors=True
60+
)
61+
assert res.status_code == 403
62+
63+
def test_private_node_collections_list_logged_out(self, app, private_project, collection):
64+
collection.collect_object(private_project, collector=collection.creator)
65+
res = app.get(f'/{API_BASE}nodes/{private_project._id}/collections/', expect_errors=True)
66+
assert res.status_code == 401
67+
68+
def test_multiple_collections_linked_to_node(self, app, public_project, user):
69+
collection1 = CollectionFactory(creator=user)
70+
collection2 = CollectionFactory(creator=user)
71+
collection3 = CollectionFactory(creator=user)
72+
73+
collection1.collect_object(public_project, collector=user)
74+
collection2.collect_object(public_project, collector=user)
75+
collection3.collect_object(public_project, collector=user)
76+
77+
res = app.get(f'/{API_BASE}nodes/{public_project._id}/collections/')
78+
assert res.status_code == 200
79+
ids = [col['id'] for col in res.json['data']]
80+
assert set(ids) == {collection1._id, collection2._id, collection3._id}
81+
82+
def test_remove_node_from_collection(self, app, public_project, user):
83+
collection1 = CollectionFactory(creator=user)
84+
collection2 = CollectionFactory(creator=user)
85+
86+
collection1.collect_object(public_project, collector=user)
87+
collection2.collect_object(public_project, collector=user)
88+
89+
# Remove public_project from collection2
90+
collection2.collectionsubmission_set.first().delete()
91+
92+
res = app.get(f'/{API_BASE}nodes/{public_project._id}/collections/')
93+
assert res.status_code == 200
94+
ids = [col['id'] for col in res.json['data']]
95+
assert set(ids) == {collection1._id}
96+
97+
def test_unlinked_collections_not_included(self, app, public_project, user):
98+
linked = CollectionFactory(creator=user)
99+
unlinked = CollectionFactory(creator=user)
100+
101+
linked.collect_object(public_project, collector=user)
102+
103+
res = app.get(f'/{API_BASE}nodes/{public_project._id}/collections/')
104+
assert res.status_code == 200
105+
ids = [col['id'] for col in res.json['data']]
106+
assert linked._id in ids
107+
assert unlinked._id not in ids

api_tests/nodes/views/test_node_detail.py

+9
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,13 @@ def test_node_shows_wiki_relationship_based_on_disabled_status_and_version(self,
334334
res = app.get(url, auth=user.auth)
335335
assert 'wikis' in res.json['data']['relationships']
336336

337+
def test_node_shows_collected_in_relationship(self, app, user, project_public, url_public):
338+
res = app.get(
339+
url_public,
340+
auth=user.auth
341+
)
342+
assert 'collected_in' in res.json['data']['relationships']
343+
337344
def test_preprint_field(self, app, user, user_two, project_public, url_public):
338345
# Returns true if project holds supplemental material for a preprint a user can view
339346
# Published preprint, admin_contrib
@@ -617,6 +624,8 @@ def test_current_user_permissions(self, app, user, url_public, project_public, u
617624
assert res.json['data']['attributes']['current_user_is_contributor_or_group_member'] is False
618625
assert res.json['data']['attributes']['current_user_is_contributor'] is False
619626

627+
assert res.json['data']['relationships']['collected_in']
628+
620629
def test_current_user_permissions_vol(self, app, user, url_public, project_public):
621630
'''
622631
User's including view only link query params should get ONLY read permissions even if they are admins etc.

api_tests/registrations/views/test_registration_detail.py

+4
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,10 @@ def test_registration_detail(
239239

240240
class TestRegistrationUpdateTestCase:
241241

242+
@pytest.fixture()
243+
def user(self):
244+
return AuthUserFactory()
245+
242246
@pytest.fixture()
243247
def read_only_contributor(self):
244248
return AuthUserFactory()

framework/auth/oauth_scopes.py

+3
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ class CoreScopes:
4545

4646
MEETINGS_READ = 'meetings.base_read'
4747

48+
NODE_COLLECTIONS_READ = 'node_collections_read'
49+
NODE_COLLECTIONS_WRITE = 'node_collections_write'
50+
4851
NODE_BASE_READ = 'nodes.base_read'
4952
NODE_BASE_WRITE = 'nodes.base_write'
5053

0 commit comments

Comments
 (0)