Skip to content

Commit 1b21f10

Browse files
committed
Merge branch 'release/19.22.0'
2 parents f2f9548 + 4c04016 commit 1b21f10

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+2564
-443
lines changed

CHANGELOG

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO.
44

5+
19.22.0 (2019-08-14)
6+
===================
7+
- APIv2: Editable registrations
8+
- APIv2.16 treats subjects as relationships instead of attributes
9+
510
19.21.0 (2019-08-12)
611
===================
712
- PageCounter optimization part I: split _id field into four columns, `action`,

api/base/exceptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def get_resource_object_member(error_key, context):
1111
if field:
1212
return 'relationships' if isinstance(field, RelationshipField) else 'attributes'
1313
# If field cannot be found (where read/write operations have different serializers,
14-
# assume error was in 'attributes' by default
14+
# or fields serialized on __init__, assume error was in 'attributes' by default
1515
return 'attributes'
1616

1717
def dict_error_formatting(errors, context, index=None):

api/base/filters.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -115,16 +115,21 @@ def _get_field_or_error(self, field_name):
115115
116116
:raises InvalidFilterError: If the filter field is not valid
117117
"""
118-
serializer_class = self.serializer_class
119-
if field_name not in serializer_class._declared_fields:
118+
predeclared_fields = self.serializer_class._declared_fields
119+
initialized_fields = self.get_serializer().fields if hasattr(self, 'get_serializer') else {}
120+
serializer_fields = predeclared_fields.copy()
121+
# Merges fields that were declared on serializer with fields that may have been dynamically added
122+
serializer_fields.update(initialized_fields)
123+
124+
if field_name not in serializer_fields:
120125
raise InvalidFilterError(detail="'{0}' is not a valid field for this endpoint.".format(field_name))
121-
if field_name not in getattr(serializer_class, 'filterable_fields', set()):
126+
if field_name not in getattr(self.serializer_class, 'filterable_fields', set()):
122127
raise InvalidFilterFieldError(parameter='filter', value=field_name)
123-
field = serializer_class._declared_fields[field_name]
128+
field = serializer_fields[field_name]
124129
# You cannot filter on deprecated fields.
125130
if isinstance(field, ShowIfVersion) and utils.is_deprecated(self.request.version, field.min_version, field.max_version):
126131
raise InvalidFilterFieldError(parameter='filter', value=field_name)
127-
return serializer_class._declared_fields[field_name]
132+
return serializer_fields[field_name]
128133

129134
def _validate_operator(self, field, field_name, op):
130135
"""
@@ -311,7 +316,7 @@ def convert_value(self, value, field):
311316
value=value,
312317
field_type='date',
313318
)
314-
elif isinstance(field, (self.RELATIONSHIP_FIELDS, ser.SerializerMethodField)):
319+
elif isinstance(field, (self.RELATIONSHIP_FIELDS, ser.SerializerMethodField, ser.ManyRelatedField)):
315320
if value == 'null':
316321
value = None
317322
return value
@@ -439,11 +444,14 @@ def postprocess_query_param(self, key, field_name, operation):
439444
)
440445
operation['op'] = 'in'
441446
if field_name == 'subjects':
442-
if Subject.objects.filter(_id=operation['value']).exists():
443-
operation['source_field_name'] = 'subjects___id'
444-
else:
445-
operation['source_field_name'] = 'subjects__text'
446-
operation['op'] = 'iexact'
447+
self.postprocess_subject_query_param(operation)
448+
449+
def postprocess_subject_query_param(self, operation):
450+
if Subject.objects.filter(_id=operation['value']).exists():
451+
operation['source_field_name'] = 'subjects___id'
452+
else:
453+
operation['source_field_name'] = 'subjects__text'
454+
operation['op'] = 'iexact'
447455

448456
def get_filtered_queryset(self, field_name, params, default_queryset):
449457
"""filters default queryset based on the serializer field type"""
@@ -511,12 +519,7 @@ def postprocess_query_param(self, key, field_name, operation):
511519
operation['source_field_name'] = 'guids___id'
512520

513521
if field_name == 'subjects':
514-
try:
515-
Subject.objects.get(_id=operation['value'])
516-
operation['source_field_name'] = 'subjects___id'
517-
except Subject.DoesNotExist:
518-
operation['source_field_name'] = 'subjects__text'
519-
operation['op'] = 'iexact'
522+
self.postprocess_subject_query_param(operation)
520523

521524
def preprints_queryset(self, base_queryset, auth_user, allow_contribs=True, public_only=False):
522525
return Preprint.objects.can_view(

api/base/settings/defaults.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@
166166
'2.13',
167167
'2.14',
168168
'2.15',
169+
'2.16',
169170
),
170171
'DEFAULT_FILTER_BACKENDS': ('api.base.filters.OSFOrderingFilter',),
171172
'DEFAULT_PAGINATION_CLASS': 'api.base.pagination.JSONAPIPagination',

api/base/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
url(r'^requests/', include('api.requests.urls', namespace='requests')),
5757
url(r'^scopes/', include('api.scopes.urls', namespace='scopes')),
5858
url(r'^search/', include('api.search.urls', namespace='search')),
59+
url(r'^subjects/', include('api.subjects.urls', namespace='subjects')),
5960
url(r'^subscriptions/', include('api.subscriptions.urls', namespace='subscriptions')),
6061
url(r'^taxonomies/', include('api.taxonomies.urls', namespace='taxonomies')),
6162
url(r'^test/', include('api.test.urls', namespace='test')),

api/base/views.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,6 @@ def partial(item):
129129
with transaction.atomic():
130130
ret = view.handle_exception(e).data
131131

132-
# Allow request to be gc'd
133-
ser._context = None
134-
135132
# Cache our final result
136133
cache[_cache_key] = ret
137134

api/collections/permissions.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from rest_framework import permissions
55
from rest_framework.exceptions import NotFound
66

7-
from api.base.utils import get_user_auth
7+
from api.base.utils import get_user_auth, assert_resource_type
88
from osf.models import AbstractNode, Preprint, Collection, CollectionSubmission, CollectionProvider
99
from osf.utils.permissions import WRITE, ADMIN
1010

@@ -45,8 +45,14 @@ def has_object_permission(self, request, view, obj):
4545
return auth.user and (accepting_submissions or auth.user.has_perm('write_collection', obj))
4646

4747
class CanUpdateDeleteCGMOrPublic(permissions.BasePermission):
48+
49+
acceptable_models = (CollectionSubmission, )
50+
4851
def has_object_permission(self, request, view, obj):
49-
assert isinstance(obj, CollectionSubmission), 'obj must be a CollectionSubmission, got {}'.format(obj)
52+
if isinstance(obj, dict):
53+
obj = obj.get('self', None)
54+
55+
assert_resource_type(obj, self.acceptable_models)
5056
collection = obj.collection
5157
auth = get_user_auth(request)
5258
if request.method in permissions.SAFE_METHODS:

api/collections/serializers.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,22 @@ class Meta:
200200
related_view_kwargs={'guids': '<guid._id>'},
201201
always_embed=True,
202202
)
203+
204+
@property
205+
def subjects_related_view(self):
206+
# Overrides TaxonomizableSerializerMixin
207+
return 'collections:collected-metadata-subjects'
208+
209+
@property
210+
def subjects_self_view(self):
211+
# Overrides TaxonomizableSerializerMixin
212+
return 'collections:collected-metadata-relationships-subjects'
213+
214+
@property
215+
def subjects_view_kwargs(self):
216+
# Overrides TaxonomizableSerializerMixin
217+
return {'collection_id': '<collection._id>', 'cgm_id': '<guid._id>'}
218+
203219
collected_type = ser.CharField(required=False)
204220
status = ser.CharField(required=False)
205221
volume = ser.CharField(required=False)
@@ -220,12 +236,8 @@ def update(self, obj, validated_data):
220236
if validated_data and 'subjects' in validated_data:
221237
auth = get_user_auth(self.context['request'])
222238
subjects = validated_data.pop('subjects', None)
223-
try:
224-
obj.set_subjects(subjects, auth)
225-
except PermissionsError as e:
226-
raise exceptions.PermissionDenied(detail=str(e))
227-
except (ValueError, NodeStateError) as e:
228-
raise exceptions.ValidationError(detail=str(e))
239+
self.update_subjects(obj, subjects, auth)
240+
229241
if 'status' in validated_data:
230242
obj.status = validated_data.pop('status')
231243
if 'collected_type' in validated_data:

api/collections/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
url(r'^(?P<collection_id>\w+)/$', views.CollectionDetail.as_view(), name=views.CollectionDetail.view_name),
1010
url(r'^(?P<collection_id>\w+)/collected_metadata/$', views.CollectedMetaList.as_view(), name=views.CollectedMetaList.view_name),
1111
url(r'^(?P<collection_id>\w+)/collected_metadata/(?P<cgm_id>\w+)/$', views.CollectedMetaDetail.as_view(), name=views.CollectedMetaDetail.view_name),
12+
url(r'^(?P<collection_id>\w+)/collected_metadata/(?P<cgm_id>\w+)/subjects/$', views.CollectedMetaSubjectsList.as_view(), name=views.CollectedMetaSubjectsList.view_name),
13+
url(r'^(?P<collection_id>\w+)/collected_metadata/(?P<cgm_id>\w+)/relationships/subjects/$', views.CollectedMetaSubjectsRelationship.as_view(), name=views.CollectedMetaSubjectsRelationship.view_name),
1214
url(r'^(?P<collection_id>\w+)/linked_nodes/$', views.LinkedNodesList.as_view(), name=views.LinkedNodesList.view_name),
1315
url(r'^(?P<collection_id>\w+)/linked_preprints/$', views.LinkedPreprintsList.as_view(), name=views.LinkedPreprintsList.view_name),
1416
url(r'^(?P<collection_id>\w+)/linked_registrations/$', views.LinkedRegistrationsList.as_view(), name=views.LinkedRegistrationsList.view_name),

api/collections/views.py

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from api.base import generic_bulk_views as bulk_views
99
from api.base import permissions as base_permissions
1010
from api.base.filters import ListFilterMixin
11+
from api.base.parsers import JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON
12+
1113
from api.base.views import JSONAPIBaseView
1214
from api.base.views import BaseLinkedList
1315
from api.base.views import LinkedNodesRelationship
@@ -34,6 +36,7 @@
3436
)
3537
from api.nodes.serializers import NodeSerializer
3638
from api.preprints.serializers import PreprintSerializer
39+
from api.subjects.views import SubjectRelationshipBaseView, BaseResourceSubjectsList
3740
from api.registrations.serializers import RegistrationSerializer
3841
from osf.models import (
3942
AbstractNode,
@@ -72,6 +75,18 @@ def collection_preprints(self, collection, user):
7275
), user=user,
7376
)
7477

78+
def get_collection_submission(self, check_object_permissions=True):
79+
collection_submission = get_object_or_error(
80+
CollectionSubmission,
81+
Q(collection=Collection.load(self.kwargs['collection_id']), guid___id=self.kwargs['cgm_id']),
82+
self.request,
83+
'submission',
84+
)
85+
# May raise a permission denied
86+
if check_object_permissions:
87+
self.check_object_permissions(self.request, collection_submission)
88+
return collection_submission
89+
7590

7691
class CollectionList(JSONAPIBaseView, bulk_views.BulkUpdateJSONAPIView, bulk_views.BulkDestroyJSONAPIView, bulk_views.ListBulkCreateJSONAPIView, ListFilterMixin, CollectionMixin):
7792
"""Organizer Collections organize projects and components. *Writeable*.
@@ -120,7 +135,7 @@ class CollectionList(JSONAPIBaseView, bulk_views.BulkUpdateJSONAPIView, bulk_vie
120135
New Organizer Collections are created by issuing a POST request to this endpoint. The `title` field is
121136
mandatory. All other fields not listed above will be ignored. If the Organizer Collection creation is successful
122137
the API will return a 201 response with the representation of the new node in the body.
123-
For the new Collection's canonical URL, see the `/links/self` field of the response.
138+
For the new Collection canonical URL, see the `/links/self` field of the response.
124139
125140
##Query Params
126141
@@ -297,6 +312,7 @@ def perform_destroy(self, instance):
297312
collection = self.get_object()
298313
collection.delete()
299314

315+
300316
class CollectedMetaList(JSONAPIBaseView, generics.ListCreateAPIView, CollectionMixin, ListFilterMixin):
301317
permission_classes = (
302318
drf_permissions.IsAuthenticatedOrReadOnly,
@@ -308,7 +324,7 @@ class CollectedMetaList(JSONAPIBaseView, generics.ListCreateAPIView, CollectionM
308324

309325
model_class = CollectionSubmission
310326
serializer_class = CollectionSubmissionSerializer
311-
view_category = 'collected-metadata'
327+
view_category = 'collections'
312328
view_name = 'collected-metadata-list'
313329

314330
def get_serializer_class(self):
@@ -339,20 +355,14 @@ class CollectedMetaDetail(JSONAPIBaseView, generics.RetrieveUpdateDestroyAPIView
339355
required_write_scopes = [CoreScopes.COLLECTED_META_WRITE]
340356

341357
serializer_class = CollectionSubmissionSerializer
342-
view_category = 'collected-metadata'
358+
view_category = 'collections'
343359
view_name = 'collected-metadata-detail'
344360

361+
parser_classes = (JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON,)
362+
345363
# overrides RetrieveAPIView
346364
def get_object(self):
347-
cgm = get_object_or_error(
348-
CollectionSubmission,
349-
Q(collection=Collection.load(self.kwargs['collection_id']), guid___id=self.kwargs['cgm_id']),
350-
self.request,
351-
'submission',
352-
)
353-
# May raise a permission denied
354-
self.check_object_permissions(self.request, cgm)
355-
return cgm
365+
return self.get_collection_submission()
356366

357367
def perform_destroy(self, instance):
358368
# Skip collection permission check -- perms class checks when getting CGM
@@ -363,6 +373,43 @@ def perform_update(self, serializer):
363373
serializer.save()
364374

365375

376+
class CollectedMetaSubjectsList(BaseResourceSubjectsList, CollectionMixin):
377+
"""The documentation for this endpoint can be found [here](https://developer.osf.io/#operation/collected_meta_subjects).
378+
"""
379+
permission_classes = (
380+
drf_permissions.IsAuthenticatedOrReadOnly,
381+
CanUpdateDeleteCGMOrPublic,
382+
base_permissions.TokenHasScope,
383+
)
384+
385+
required_read_scopes = [CoreScopes.COLLECTED_META_READ]
386+
387+
view_category = 'collections'
388+
view_name = 'collected-metadata-subjects'
389+
390+
def get_resource(self):
391+
return self.get_collection_submission()
392+
393+
394+
class CollectedMetaSubjectsRelationship(SubjectRelationshipBaseView, CollectionMixin):
395+
"""The documentation for this endpoint can be found [here](https://developer.osf.io/#operation/collected_meta_subjects_relationship).
396+
"""
397+
permission_classes = (
398+
drf_permissions.IsAuthenticatedOrReadOnly,
399+
CanUpdateDeleteCGMOrPublic,
400+
base_permissions.TokenHasScope,
401+
)
402+
403+
required_read_scopes = [CoreScopes.COLLECTED_META_READ]
404+
required_write_scopes = [CoreScopes.COLLECTED_META_WRITE]
405+
406+
view_category = 'collections'
407+
view_name = 'collected-metadata-relationships-subjects'
408+
409+
def get_resource(self, check_object_permissions=True):
410+
return self.get_collection_submission(check_object_permissions)
411+
412+
366413
class LinkedNodesList(BaseLinkedList, CollectionMixin, NodeOptimizationMixin):
367414
"""List of nodes linked to this node. *Read-only*.
368415

api/guids/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def to_representation(self, obj):
8282
ser = resolve(reverse(
8383
get_related_view(obj),
8484
kwargs={'node_id': obj._id, 'version': self.context['view'].kwargs.get('version', '2')},
85-
)).func.cls.serializer_class()
85+
)).func.cls.serializer_class(context=self.context)
8686
[ser.context.update({k: v}) for k, v in self.context.items()]
8787
return ser.to_representation(obj)
8888
return super(GuidSerializer, self).to_representation(obj)

api/nodes/serializers.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,21 @@ class NodeSerializer(TaxonomizableSerializerMixin, JSONAPISerializer):
526526
related_view_kwargs={'node_id': '<_id>'},
527527
))
528528

529+
@property
530+
def subjects_related_view(self):
531+
# Overrides TaxonomizableSerializerMixin
532+
return 'nodes:node-subjects'
533+
534+
@property
535+
def subjects_view_kwargs(self):
536+
# Overrides TaxonomizableSerializerMixin
537+
return {'node_id': '<_id>'}
538+
539+
@property
540+
def subjects_self_view(self):
541+
# Overrides TaxonomizableSerializerMixin
542+
return 'nodes:node-relationships-subjects'
543+
529544
def get_current_user_permissions(self, obj):
530545
"""
531546
Returns the logged-in user's permissions to the
@@ -827,14 +842,7 @@ def update(self, node, validated_data):
827842
node.save()
828843
if 'subjects' in validated_data:
829844
subjects = validated_data.pop('subjects', None)
830-
try:
831-
node.set_subjects(subjects, auth)
832-
except PermissionsError as e:
833-
raise exceptions.PermissionDenied(detail=str(e))
834-
except ValueError as e:
835-
raise exceptions.ValidationError(detail=str(e))
836-
except NodeStateError as e:
837-
raise exceptions.ValidationError(detail=str(e))
845+
self.update_subjects(node, subjects, auth)
838846

839847
try:
840848
node.update(validated_data, auth=auth)
@@ -978,14 +986,14 @@ def get_folder_info(self, data, addon_name):
978986
return set_folder, folder_info
979987

980988
def get_account_or_error(self, addon_name, external_account_id, auth):
981-
external_account = ExternalAccount.load(external_account_id)
982-
if not external_account:
983-
raise exceptions.NotFound('Unable to find requested account.')
984-
if not auth.user.external_accounts.filter(id=external_account.id).exists():
985-
raise exceptions.PermissionDenied('Requested action requires account ownership.')
986-
if external_account.provider != addon_name:
987-
raise Conflict('Cannot authorize the {} addon with an account for {}'.format(addon_name, external_account.provider))
988-
return external_account
989+
external_account = ExternalAccount.load(external_account_id)
990+
if not external_account:
991+
raise exceptions.NotFound('Unable to find requested account.')
992+
if not auth.user.external_accounts.filter(id=external_account.id).exists():
993+
raise exceptions.PermissionDenied('Requested action requires account ownership.')
994+
if external_account.provider != addon_name:
995+
raise Conflict('Cannot authorize the {} addon with an account for {}'.format(addon_name, external_account.provider))
996+
return external_account
989997

990998
def should_call_set_folder(self, folder_info, instance, auth, node_settings):
991999
if (folder_info and not ( # If we have folder information to set

0 commit comments

Comments
 (0)