Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/APIv2 Node Settings [PLAT-924] #8536

Merged
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d741ec8
Begin adding Node Settings to APIv2.
pattisdr Jul 10, 2018
e4bde4d
Make sure user is viewing or has WRITE permissions in IsContributor
erinspace Jul 10, 2018
60031ad
Add NodeSettingsUpdateSerializer for updating fields on NodeSettings
erinspace Jul 10, 2018
010492a
Add node settings test file.
pattisdr Jul 10, 2018
0d03e56
Add missed items to IsContributor permission class and NodeSettings s…
pattisdr Jul 10, 2018
d5a861d
Add tests for the NodeSettingsSerializer and GET requests
erinspace Jul 10, 2018
98b94b5
Add test for view_only_links relationship
erinspace Jul 11, 2018
b6cd5f0
Add APIv2 update tests for NodeSettings.
pattisdr Jul 10, 2018
a62f24c
Merge branch 'feature/node_settings_apiV2' of https://github.com/patt…
pattisdr Jul 11, 2018
9c2026e
Add version 2.9 to API and add to serializer context so addons aren't…
pattisdr Jul 11, 2018
2e43308
Expose settings relationship on Node serializer only.
pattisdr Jul 11, 2018
ab33946
Modify is_deprecated so only a min_version or max_version can be spec…
pattisdr Jul 11, 2018
c21ac61
Add PUT permissions tests for APIv2 NodeSettings.
pattisdr Jul 13, 2018
fb7db32
Move method for setting access requests to node model
erinspace Jul 18, 2018
a3468e5
Use new access_requests_enabled method on node, add tests for logging
erinspace Jul 18, 2018
a6733d0
Check for logging on other node and wiki actions that have associated…
erinspace Jul 18, 2018
d74bf6a
Make update methods consistent in passing auth
erinspace Jul 18, 2018
bee8d6a
Override to_representation on NodeSettingsUpdateSerializer to use a d…
pattisdr Jul 19, 2018
ab17e69
Merge branch 'develop' of https://github.com/CenterForOpenScience/osf…
pattisdr Jul 19, 2018
dd681e7
Fix NodeSettings tests.
pattisdr Jul 19, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/base/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,12 @@ def should_hide(self, instance):

class HideIfWikiDisabled(ConditionalField):
"""
If wiki is disabled, don't show relationship field, only available in version 2.8
If wiki is disabled, don't show relationship field, only available after 2.7
"""

def should_hide(self, instance):
request = self.context.get('request')
return not utils.is_deprecated(request.version, '2.8', '2.8') and not instance.has_addon('wiki')
return not utils.is_deprecated(request.version, min_version='2.8') and not instance.has_addon('wiki')


class HideIfNotNodePointerLog(ConditionalField):
Expand Down
1 change: 1 addition & 0 deletions api/base/settings/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
'2.6',
'2.7',
'2.8',
'2.9',
),
'DEFAULT_FILTER_BACKENDS': ('api.base.filters.OSFOrderingFilter',),
'DEFAULT_PAGINATION_CLASS': 'api.base.pagination.JSONAPIPagination',
Expand Down
6 changes: 4 additions & 2 deletions api/base/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,10 @@ def has_admin_scope(request):
return set(ComposedScopes.ADMIN_LEVEL).issubset(normalize_scopes(token.attributes['accessTokenScope']))


def is_deprecated(request_version, min_version, max_version):
if request_version < min_version or request_version > max_version:
def is_deprecated(request_version, min_version=None, max_version=None):
if not min_version and not max_version:
raise NotImplementedError('Must specify min or max version.')
if min_version and request_version < min_version or max_version and request_version > max_version:
return True
return False

Expand Down
10 changes: 10 additions & 0 deletions api/nodes/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ def has_object_permission(self, request, view, obj):
return obj.has_permission(auth.user, osf_permissions.ADMIN)


class IsContributor(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
assert isinstance(obj, AbstractNode), 'obj must be an Node, got {}'.format(obj)
auth = get_user_auth(request)
if request.method in permissions.SAFE_METHODS:
return obj.is_contributor(auth.user)
else:
return obj.has_permission(auth.user, 'write')


class IsAdminOrReviewer(permissions.BasePermission):
"""
Prereg admins can update draft registrations.
Expand Down
156 changes: 155 additions & 1 deletion api/nodes/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ class NodeSerializer(TaxonomizableSerializerMixin, JSONAPISerializer):
fork = ser.BooleanField(read_only=True, source='is_fork')
collection = ser.BooleanField(read_only=True, source='is_collection')
tags = ValuesListField(attr_name='name', child=ser.CharField(), required=False)
access_requests_enabled = ser.BooleanField(read_only=False, required=False)
access_requests_enabled = ShowIfVersion(ser.BooleanField(read_only=False, required=False), min_version='2.0', max_version='2.8')
node_license = NodeLicenseSerializer(required=False, source='license')
analytics_key = ShowIfAdminScopeOrAnonymous(ser.CharField(read_only=True, source='keenio_read_key'))
template_from = ser.CharField(required=False, allow_blank=False, allow_null=False,
Expand Down Expand Up @@ -270,6 +270,11 @@ class NodeSerializer(TaxonomizableSerializerMixin, JSONAPISerializer):
related_view_kwargs={'node_id': '<_id>'}
)

settings = RelationshipField(
related_view='nodes:node-settings',
related_view_kwargs={'node_id': '<_id>'}
)

wikis = HideIfWikiDisabled(RelationshipField(
related_view='nodes:node-wikis',
related_view_kwargs={'node_id': '<_id>'}
Expand Down Expand Up @@ -1314,3 +1319,152 @@ def update(self, link, validated_data):

link.save()
return link


class NodeSettingsSerializer(JSONAPISerializer):
id = IDField(source='_id', read_only=True)
type = TypeField()
access_requests_enabled = ser.BooleanField()
anyone_can_comment = ser.SerializerMethodField()
anyone_can_edit_wiki = ser.SerializerMethodField()
wiki_enabled = ser.SerializerMethodField()
redirect_link_enabled = ser.SerializerMethodField()
redirect_link_url = ser.SerializerMethodField()
redirect_link_label = ser.SerializerMethodField()

view_only_links = RelationshipField(
related_view='nodes:node-view-only-links',
related_view_kwargs={'node_id': '<_id>'},
)

links = LinksField({
'self': 'get_absolute_url'
})

def get_anyone_can_comment(self, obj):
return obj.comment_level == 'public'

def get_wiki_enabled(self, obj):
return self.context['wiki_addon'] is not None

def get_anyone_can_edit_wiki(self, obj):
wiki_addon = self.context['wiki_addon']
return wiki_addon.is_publicly_editable if wiki_addon else None

def get_redirect_link_enabled(self, obj):
return self.context['forward_addon'] is not None

def get_redirect_link_url(self, obj):
forward_addon = self.context['forward_addon']
return forward_addon.url if forward_addon else None

def get_redirect_link_label(self, obj):
forward_addon = self.context['forward_addon']
return forward_addon.label if forward_addon else None

def get_absolute_url(self, obj):
return absolute_reverse(
'nodes:node-settings',
kwargs={
'node_id': self.context['request'].parser_context['kwargs']['node_id'],
'version': self.context['request'].parser_context['kwargs']['version']
}
)

class Meta:
type_ = 'node-settings'


class NodeSettingsUpdateSerializer(NodeSettingsSerializer):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@erinspace pointed out writeable_method_fields which allow writes to a SerializerMethodField. We explored this, but ended up opting for an UpdateSerializer for writes, so we could keep our individual field validation. The NodeSettings serializer is tricky because most of the fields do not actually reside on the node.

anyone_can_comment = ser.BooleanField(write_only=True, required=False)
wiki_enabled = ser.BooleanField(write_only=True, required=False)
anyone_can_edit_wiki = ser.BooleanField(write_only=True, required=False)
redirect_link_enabled = ser.BooleanField(write_only=True, required=False)
redirect_link_url = ser.URLField(write_only=True, required=False)
redirect_link_label = ser.CharField(max_length=50, write_only=True, required=False)

def update(self, obj, validated_data):
user = self.context['request'].user
auth = get_user_auth(self.context['request'])
admin_only_field_names = [
'access_requests_enabled',
'anyone_can_comment',
'anyone_can_edit_wiki',
'wiki_enabled',
]

if set(validated_data.keys()).intersection(set(admin_only_field_names)) and not obj.has_permission(user, 'admin'):
raise exceptions.PermissionDenied

self.update_node_fields(obj, validated_data)
self.update_wiki_fields(obj, validated_data, auth)
self.update_forward_fields(obj, validated_data, auth)
return obj

def update_node_fields(self, obj, validated_data):
access_requests_enabled = validated_data.get('access_requests_enabled')
anyone_can_comment = validated_data.get('anyone_can_comment')
save_node = False

if access_requests_enabled is not None:
obj.access_requests_enabled = access_requests_enabled
save_node = True
if anyone_can_comment is not None:
obj.comment_level = 'public' if anyone_can_comment else 'private'
save_node = True
if save_node:
obj.save()

def update_wiki_fields(self, obj, validated_data, auth):
wiki_enabled = validated_data.get('wiki_enabled')
anyone_can_edit_wiki = validated_data.get('anyone_can_edit_wiki')
wiki_addon = self.context['wiki_addon']

if wiki_enabled is not None:
wiki_addon = self.enable_or_disable_addon(obj, wiki_enabled, 'wiki', auth)

if anyone_can_edit_wiki is not None:
if not obj.is_public and anyone_can_edit_wiki:
raise exceptions.ValidationError('To allow all OSF users to edit the wiki, the project must be public.')
if wiki_addon:
wiki_addon.is_publicly_editable = True if anyone_can_edit_wiki else False
wiki_addon.save()
else:
raise exceptions.ValidationError('You must have the wiki enabled before changing wiki settings.')

def update_forward_fields(self, obj, validated_data, auth):
redirect_link_enabled = validated_data.get('redirect_link_enabled')
redirect_link_url = validated_data.get('redirect_link_url')
redirect_link_label = validated_data.get('redirect_link_label')

save_forward = False
forward_addon = self.context['forward_addon']

if redirect_link_enabled is not None:
if not redirect_link_url and redirect_link_enabled:
raise exceptions.ValidationError('You must include a redirect URL to enable a redirect.')
forward_addon = self.enable_or_disable_addon(obj, redirect_link_enabled, 'forward', auth)

if redirect_link_url is not None:
if not forward_addon:
raise exceptions.ValidationError('You must first set redirect_link_enabled to True before specifying a redirect link URL.')
forward_addon.url = redirect_link_url
save_forward = True

if redirect_link_label is not None:
if not forward_addon:
raise exceptions.ValidationError('You must first set redirect_link_enabled to True before specifying a redirect link label.')
forward_addon.label = redirect_link_label
save_forward = True

if save_forward:
forward_addon.save()

def enable_or_disable_addon(self, obj, should_enable, addon_name, auth):
"""
Returns addon, if exists, otherwise returns None
"""
addon = obj.get_or_add_addon(addon_name, auth=auth) if should_enable else obj.delete_addon(addon_name, auth)
if type(addon) == bool:
addon = None
return addon
1 change: 1 addition & 0 deletions api/nodes/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
url(r'^(?P<node_id>\w+)/relationships/linked_nodes/$', views.NodeLinkedNodesRelationship.as_view(), name=views.NodeLinkedNodesRelationship.view_name),
url(r'^(?P<node_id>\w+)/relationships/linked_registrations/$', views.NodeLinkedRegistrationsRelationship.as_view(), name=views.NodeLinkedRegistrationsRelationship.view_name),
url(r'^(?P<node_id>\w+)/requests/$', views.NodeRequestListCreate.as_view(), name=views.NodeRequestListCreate.view_name),
url(r'^(?P<node_id>\w+)/settings/$', views.NodeSettings.as_view(), name=views.NodeSettings.view_name),
url(r'^(?P<node_id>\w+)/view_only_links/$', views.NodeViewOnlyLinksList.as_view(), name=views.NodeViewOnlyLinksList.view_name),
url(r'^(?P<node_id>\w+)/view_only_links/(?P<link_id>\w+)/$', views.NodeViewOnlyLinkDetail.as_view(), name=views.NodeViewOnlyLinkDetail.view_name),
url(r'^(?P<node_id>\w+)/wikis/$', views.NodeWikiList.as_view(), name=views.NodeWikiList.view_name),
Expand Down
39 changes: 39 additions & 0 deletions api/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
ContributorDetailPermissions,
ReadOnlyIfRegistration,
IsAdminOrReviewer,
IsContributor,
WriteOrPublicForRelationshipInstitutions,
ExcludeWithdrawals,
NodeLinksShowIfVersion,
Expand All @@ -88,6 +89,8 @@
NodeContributorsCreateSerializer,
NodeViewOnlyLinkSerializer,
NodeViewOnlyLinkUpdateSerializer,
NodeSettingsSerializer,
NodeSettingsUpdateSerializer,
NodeCitationSerializer,
NodeCitationStyleSerializer
)
Expand Down Expand Up @@ -1909,3 +1912,39 @@ def get_default_queryset(self):

def get_queryset(self):
return self.get_queryset_from_request()


class NodeSettings(JSONAPIBaseView, generics.RetrieveUpdateAPIView, NodeMixin):
permission_classes = (
drf_permissions.IsAuthenticatedOrReadOnly,
base_permissions.TokenHasScope,
IsContributor,
)

required_read_scopes = [CoreScopes.NODE_SETTINGS_READ]
required_write_scopes = [CoreScopes.NODE_SETTINGS_WRITE]

serializer_class = NodeSettingsSerializer

view_category = 'nodes'
view_name = 'node-settings'

# overrides RetrieveUpdateAPIView
def get_object(self):
return self.get_node()

def get_serializer_class(self):
if self.request.method == 'PUT' or self.request.method == 'PATCH':
return NodeSettingsUpdateSerializer
return NodeSettingsSerializer

def get_serializer_context(self):
"""
Extra context for NodeSettingsSerializer - this will prevent loading
addons multiple times in SerializerMethodFields
"""
context = super(NodeSettings, self).get_serializer_context()
node = self.get_node(check_object_permissions=False)
context['wiki_addon'] = node.get_addon('wiki')
context['forward_addon'] = node.get_addon('forward')
return context
5 changes: 5 additions & 0 deletions api/registrations/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ class BaseRegistrationSerializer(NodeSerializer):
related_view_kwargs={'metaschema_id': '<registered_schema_id>'}
)

settings = HideIfRegistration(RelationshipField(
related_view='nodes:node-settings',
related_view_kwargs={'node_id': '<_id>'}
))

registrations = HideIfRegistration(RelationshipField(
related_view='nodes:node-registrations',
related_view_kwargs={'node_id': '<_id>'}
Expand Down
2 changes: 1 addition & 1 deletion api_tests/base/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def test_registration_serializer(self):
'preprint',
'subjects']
# fields that do not appear on registrations
non_registration_fields = ['registrations', 'draft_registrations', 'templated_by_count']
non_registration_fields = ['registrations', 'draft_registrations', 'templated_by_count', 'settings']

for field in NodeSerializer._declared_fields:
assert_in(field, RegistrationSerializer._declared_fields)
Expand Down
7 changes: 7 additions & 0 deletions api_tests/nodes/views/test_node_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,13 @@ def test_node_shows_wiki_relationship_based_on_disabled_status_and_version(self,
res = app.get(url, auth=user.auth)
assert 'wikis' in res.json['data']['relationships']

def test_shows_access_requests_enabled_field_based_on_version(self, app, user, project_public, url_public):
url = url_public + '?version=latest'
res = app.get(url, auth=user.auth)
assert 'access_requests_enabled' not in res.json['data']['attributes']
res = app.get(url_public + '?version=2.8', auth=user.auth)
assert 'access_requests_enabled' in res.json['data']['attributes']

def test_node_shows_correct_templated_from_count(self, app, user, project_public, url_public):
url = url_public
res = app.get(url)
Expand Down
Loading