Skip to content

Commit

Permalink
Merge pull request #8536 from pattisdr/feature/node_settings_apiV2
Browse files Browse the repository at this point in the history
Feature/APIv2 Node Settings [PLAT-924]
  • Loading branch information
sloria authored Jul 27, 2018
2 parents fef98d7 + dd681e7 commit 54b2758
Show file tree
Hide file tree
Showing 15 changed files with 693 additions and 30 deletions.
4 changes: 2 additions & 2 deletions api/base/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,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 @@ -157,6 +157,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
177 changes: 176 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,173 @@ 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):
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 to_representation(self, instance):
"""
Overriding to_representation allows using different serializers for the request and response.
"""
context = self.context
context['wiki_addon'] = instance.get_addon('wiki')
context['forward_addon'] = instance.get_addon('forward')
return NodeSettingsSerializer(instance=instance, context=context).data

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, auth)
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, auth):
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.set_access_requests_enabled(access_requests_enabled, auth=auth)
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(detail='To allow all OSF users to edit the wiki, the project must be public.')
if wiki_addon:
try:
wiki_addon.set_editing(permissions=anyone_can_edit_wiki, auth=auth, log=True)
except NodeStateError:
return
wiki_addon.save()
else:
raise exceptions.ValidationError(detail='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(detail='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(detail='You must first set redirect_link_enabled to True before specifying a redirect link URL.')
forward_addon.url = redirect_link_url
obj.add_log(
action='forward_url_changed',
params=dict(
node=obj._id,
project=obj.parent_id,
forward_url=redirect_link_url,
),
auth=auth
)
save_forward = True

if redirect_link_label is not None:
if not forward_addon:
raise exceptions.ValidationError(detail='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

0 comments on commit 54b2758

Please sign in to comment.