diff --git a/api/base/serializers.py b/api/base/serializers.py index 39048fd0a42..8dafeb80201 100644 --- a/api/base/serializers.py +++ b/api/base/serializers.py @@ -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): diff --git a/api/base/settings/defaults.py b/api/base/settings/defaults.py index 1a7b5a63a94..2d236fc9ca7 100644 --- a/api/base/settings/defaults.py +++ b/api/base/settings/defaults.py @@ -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', diff --git a/api/base/utils.py b/api/base/utils.py index 15c1d311f8f..0942d89f504 100644 --- a/api/base/utils.py +++ b/api/base/utils.py @@ -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 diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index e429e9e2076..ff87e4fc0bc 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -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. diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index 99925772216..fd5382672b4 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -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, @@ -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>'} @@ -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 diff --git a/api/nodes/urls.py b/api/nodes/urls.py index 33e4d4f79fa..41e574b0046 100644 --- a/api/nodes/urls.py +++ b/api/nodes/urls.py @@ -42,6 +42,7 @@ url(r'^(?P\w+)/relationships/linked_nodes/$', views.NodeLinkedNodesRelationship.as_view(), name=views.NodeLinkedNodesRelationship.view_name), url(r'^(?P\w+)/relationships/linked_registrations/$', views.NodeLinkedRegistrationsRelationship.as_view(), name=views.NodeLinkedRegistrationsRelationship.view_name), url(r'^(?P\w+)/requests/$', views.NodeRequestListCreate.as_view(), name=views.NodeRequestListCreate.view_name), + url(r'^(?P\w+)/settings/$', views.NodeSettings.as_view(), name=views.NodeSettings.view_name), url(r'^(?P\w+)/view_only_links/$', views.NodeViewOnlyLinksList.as_view(), name=views.NodeViewOnlyLinksList.view_name), url(r'^(?P\w+)/view_only_links/(?P\w+)/$', views.NodeViewOnlyLinkDetail.as_view(), name=views.NodeViewOnlyLinkDetail.view_name), url(r'^(?P\w+)/wikis/$', views.NodeWikiList.as_view(), name=views.NodeWikiList.view_name), diff --git a/api/nodes/views.py b/api/nodes/views.py index 93060eaa5b9..f62d3251a0a 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -68,6 +68,7 @@ ContributorDetailPermissions, ReadOnlyIfRegistration, IsAdminOrReviewer, + IsContributor, WriteOrPublicForRelationshipInstitutions, ExcludeWithdrawals, NodeLinksShowIfVersion, @@ -88,6 +89,8 @@ NodeContributorsCreateSerializer, NodeViewOnlyLinkSerializer, NodeViewOnlyLinkUpdateSerializer, + NodeSettingsSerializer, + NodeSettingsUpdateSerializer, NodeCitationSerializer, NodeCitationStyleSerializer ) @@ -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 diff --git a/api/registrations/serializers.py b/api/registrations/serializers.py index b4876bad99b..613c7d9fb52 100644 --- a/api/registrations/serializers.py +++ b/api/registrations/serializers.py @@ -180,6 +180,11 @@ class BaseRegistrationSerializer(NodeSerializer): related_view_kwargs={'metaschema_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>'} diff --git a/api_tests/base/test_serializers.py b/api_tests/base/test_serializers.py index c2bae063793..c67b2eaacf4 100644 --- a/api_tests/base/test_serializers.py +++ b/api_tests/base/test_serializers.py @@ -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) diff --git a/api_tests/nodes/views/test_node_detail.py b/api_tests/nodes/views/test_node_detail.py index 1e2a11d9f1f..641d2bf6fc5 100644 --- a/api_tests/nodes/views/test_node_detail.py +++ b/api_tests/nodes/views/test_node_detail.py @@ -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) diff --git a/api_tests/nodes/views/test_node_settings.py b/api_tests/nodes/views/test_node_settings.py new file mode 100644 index 00000000000..9015cc5c491 --- /dev/null +++ b/api_tests/nodes/views/test_node_settings.py @@ -0,0 +1,396 @@ +# -*- coding: utf-8 -*- +import pytest +from framework.auth import Auth +from api.base.settings.defaults import API_BASE +from osf_tests.factories import ( + AuthUserFactory, + ProjectFactory, + PrivateLinkFactory, +) +from osf.models import NodeLog + +@pytest.fixture() +def admin_contrib(): + return AuthUserFactory() + +@pytest.fixture() +def write_contrib(): + return AuthUserFactory() + +@pytest.fixture() +def read_contrib(): + return AuthUserFactory() + +@pytest.fixture() +def project(admin_contrib, write_contrib, read_contrib): + project = ProjectFactory(creator=admin_contrib) + project.add_contributor(write_contrib, ['write', 'read']) + project.add_contributor(read_contrib, ['read']) + project.save() + return project + +@pytest.fixture() +def url(project): + return '/{}nodes/{}/settings/'.format(API_BASE, project._id) + + +@pytest.mark.django_db +class TestNodeSettingsGet: + + @pytest.fixture() + def non_contrib(self): + return AuthUserFactory() + + def test_node_settings_detail(self, app, admin_contrib, non_contrib, write_contrib, url, project): + + # non logged in uers can't access node settings + res = app.get(url, expect_errors=True) + assert res.status_code == 401 + + # non_contrib can't access node settings + res = app.get(url, auth=non_contrib.auth, expect_errors=True) + assert res.status_code == 403 + + # read contrib can access node settings + res = app.get(url, auth=write_contrib.auth) + assert res.status_code == 200 + + # admin can access node settings + res = app.get(url, auth=admin_contrib.auth) + assert res.status_code == 200 + + # allow_access_requests + project.allow_access_requests = True + project.save() + res = app.get(url, auth=admin_contrib.auth) + attributes = res.json['data']['attributes'] + assert attributes['access_requests_enabled'] is True + + # anyone can comment + project.comment_level = 'public' + project.save() + res = app.get(url, auth=admin_contrib.auth) + attributes = res.json['data']['attributes'] + assert attributes['anyone_can_comment'] is True + + project.comment_level = 'private' + project.save() + res = app.get(url, auth=admin_contrib.auth) + attributes = res.json['data']['attributes'] + assert attributes['anyone_can_comment'] is False + + # wiki enabled + res = app.get(url, auth=admin_contrib.auth) + attributes = res.json['data']['attributes'] + assert attributes['wiki_enabled'] is True + + project.delete_addon('wiki', auth=Auth(admin_contrib)) + project.save() + res = app.get(url, auth=admin_contrib.auth) + attributes = res.json['data']['attributes'] + assert attributes['wiki_enabled'] is False + + # redirect link enabled + res = app.get(url, auth=admin_contrib.auth) + attributes = res.json['data']['attributes'] + assert attributes['redirect_link_enabled'] is False + assert attributes['redirect_link_url'] is None + assert attributes['redirect_link_label'] is None + + new_url = 'http://cool.com' + new_label = 'Test Label Woo' + forward = project.add_addon('forward', auth=Auth(admin_contrib)) + forward.url = new_url + forward.label = new_label + forward.save() + res = app.get(url, auth=admin_contrib.auth) + attributes = res.json['data']['attributes'] + assert attributes['redirect_link_enabled'] is True + assert attributes['redirect_link_url'] == new_url + assert attributes['redirect_link_label'] == new_label + + # view only links + view_only_link = PrivateLinkFactory(name='testlink') + view_only_link.nodes.add(project) + view_only_link.save() + res = app.get(url, auth=admin_contrib.auth) + assert 'view_only_links' in res.json['data']['relationships'].keys() + + +@pytest.mark.django_db +class TestNodeSettingsPUT: + @pytest.fixture() + def payload(self, project): + return { + 'data': { + 'id': project._id, + 'type': 'node-settings', + 'attributes': { + 'redirect_link_enabled': True, + 'redirect_link_url': 'https://cos.io' + } + } + } + + def test_put_permissions(self, app, project, payload, admin_contrib, write_contrib, read_contrib, url): + assert project.access_requests_enabled is True + payload['data']['attributes']['access_requests_enabled'] = False + # Logged out + res = app.put_json_api(url, payload, expect_errors=True) + assert res.status_code == 401 + + # Logged in, noncontrib + noncontrib = AuthUserFactory() + res = app.put_json_api(url, payload, auth=noncontrib.auth, expect_errors=True) + assert res.status_code == 403 + + # Logged in read + res = app.put_json_api(url, payload, auth=read_contrib.auth, expect_errors=True) + assert res.status_code == 403 + + # Logged in write (Write contribs can only change some node settings) + res = app.put_json_api(url, payload, auth=write_contrib.auth, expect_errors=True) + assert res.status_code == 403 + + # Logged in admin + res = app.put_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + + +@pytest.mark.django_db +class TestNodeSettingsUpdate: + + @pytest.fixture() + def payload(self, project): + return { + 'data': { + 'id': project._id, + 'type': 'node-settings', + 'attributes': { + } + } + } + + def test_patch_permissions(self, app, project, payload, admin_contrib, write_contrib, read_contrib, url): + payload['data']['attributes']['redirect_link_enabled'] = True + payload['data']['attributes']['redirect_link_url'] = 'https://cos.io' + # Logged out + res = app.patch_json_api(url, payload, expect_errors=True) + assert res.status_code == 401 + + # Logged in, noncontrib + noncontrib = AuthUserFactory() + res = app.patch_json_api(url, payload, auth=noncontrib.auth, expect_errors=True) + assert res.status_code == 403 + + # Logged in read + res = app.patch_json_api(url, payload, auth=read_contrib.auth, expect_errors=True) + assert res.status_code == 403 + + # Logged in write (Write contribs can only change some node settings) + res = app.patch_json_api(url, payload, auth=write_contrib.auth, expect_errors=True) + assert res.status_code == 200 + + # Logged in admin + res = app.patch_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + + def test_patch_invalid_type(self, app, project, payload, admin_contrib, url): + payload['data']['type'] = 'Invalid Type' + + # Logged in admin + res = app.patch_json_api(url, payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 409 + + def test_patch_access_requests_enabled(self, app, project, payload, admin_contrib, write_contrib, url): + assert project.access_requests_enabled is True + payload['data']['attributes']['access_requests_enabled'] = False + + # Write cannot modify this field + res = app.patch_json_api(url, payload, auth=write_contrib.auth, expect_errors=True) + assert res.status_code == 403 + + # Logged in admin + res = app.patch_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + project.reload() + assert project.access_requests_enabled is False + assert project.logs.latest().action == NodeLog.NODE_ACCESS_REQUESTS_DISABLED + assert res.json['data']['attributes']['access_requests_enabled'] is False + + payload['data']['attributes']['access_requests_enabled'] = True + # Logged in admin + res = app.patch_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + project.reload() + assert project.access_requests_enabled is True + assert project.logs.latest().action == NodeLog.NODE_ACCESS_REQUESTS_ENABLED + assert res.json['data']['attributes']['access_requests_enabled'] is True + + def test_patch_anyone_can_comment(self, app, project, payload, admin_contrib, write_contrib, url): + assert project.comment_level == 'public' + payload['data']['attributes']['anyone_can_comment'] = False + + # Write cannot modify this field + res = app.patch_json_api(url, payload, auth=write_contrib.auth, expect_errors=True) + assert res.status_code == 403 + + # Logged in admin + res = app.patch_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + project.reload() + assert project.comment_level == 'private' + assert res.json['data']['attributes']['anyone_can_comment'] is False + + payload['data']['attributes']['anyone_can_comment'] = True + # Logged in admin + res = app.patch_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + project.reload() + assert project.comment_level == 'public' + assert res.json['data']['attributes']['anyone_can_comment'] is True + + def test_patch_anyone_can_edit_wiki(self, app, project, payload, admin_contrib, write_contrib, url): + project.is_public = True + project.save() + wiki_addon = project.get_addon('wiki') + assert wiki_addon.is_publicly_editable is False + payload['data']['attributes']['anyone_can_edit_wiki'] = True + + # Write cannot modify this field + res = app.patch_json_api(url, payload, auth=write_contrib.auth, expect_errors=True) + assert res.status_code == 403 + + # Logged in admin + res = app.patch_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + wiki_addon.reload() + assert wiki_addon.is_publicly_editable is True + assert project.logs.latest().action == NodeLog.MADE_WIKI_PUBLIC + assert res.json['data']['attributes']['anyone_can_edit_wiki'] is True + + payload['data']['attributes']['anyone_can_edit_wiki'] = False + # Logged in admin + res = app.patch_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + wiki_addon.reload() + assert wiki_addon.is_publicly_editable is False + assert project.logs.latest().action == NodeLog.MADE_WIKI_PRIVATE + assert res.json['data']['attributes']['anyone_can_edit_wiki'] is False + + # Test wiki disabled in same request so cannot change wiki_settings + payload['data']['attributes']['wiki_enabled'] = False + res = app.patch_json_api(url, payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + + # Test wiki disabled so cannot change wiki settings + project.delete_addon('wiki', Auth(admin_contrib)) + res = app.patch_json_api(url, payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + + # Test wiki enabled so can change wiki settings + payload['data']['attributes']['wiki_enabled'] = True + payload['data']['attributes']['anyone_can_edit_wiki'] = True + res = app.patch_json_api(url, payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 200 + assert project.get_addon('wiki').is_publicly_editable is True + assert project.logs.latest().action == NodeLog.MADE_WIKI_PUBLIC + assert res.json['data']['attributes']['anyone_can_edit_wiki'] is True + + # If project is private, cannot change settings to allow anyone to edit wiki + project.is_public = False + project.save() + res = app.patch_json_api(url, payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'To allow all OSF users to edit the wiki, the project must be public.' + + def test_patch_wiki_enabled(self, app, project, payload, admin_contrib, write_contrib, url): + assert project.get_addon('wiki') is not None + payload['data']['attributes']['wiki_enabled'] = False + + # Write cannot modify this field + res = app.patch_json_api(url, payload, auth=write_contrib.auth, expect_errors=True) + assert res.status_code == 403 + + # Logged in admin + res = app.patch_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + assert project.get_addon('wiki') is None + assert res.json['data']['attributes']['wiki_enabled'] is False + + # Nothing happens if attempting to disable an already-disabled wiki + res = app.patch_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + assert project.get_addon('wiki') is None + + payload['data']['attributes']['wiki_enabled'] = True + # Logged in admin + res = app.patch_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + assert project.get_addon('wiki') is not None + assert res.json['data']['attributes']['wiki_enabled'] is True + + # Nothing happens if attempting to enable an already-enabled-wiki + res = app.patch_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + assert project.get_addon('wiki') is not None + + def test_redirect_link_enabled(self, app, project, payload, admin_contrib, write_contrib, url): + assert project.get_addon('forward') is None + payload['data']['attributes']['redirect_link_enabled'] = True + + label = 'My Link' + link = 'https://cos.io' + + # Redirect link not included + res = app.patch_json_api(url, payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'You must include a redirect URL to enable a redirect.' + + payload['data']['attributes']['redirect_link_url'] = link + payload['data']['attributes']['redirect_link_label'] = label + # Write contrib can modify forward related fields + res = app.patch_json_api(url, payload, auth=write_contrib.auth) + assert res.status_code == 200 + forward_addon = project.get_addon('forward') + assert forward_addon is not None + assert forward_addon.url == link + assert forward_addon.label == label + assert project.logs.latest().action == 'forward_url_changed' + assert res.json['data']['attributes']['redirect_link_enabled'] is True + assert res.json['data']['attributes']['redirect_link_url'] == link + assert res.json['data']['attributes']['redirect_link_label'] == label + + # Attempting to set redirect_link_url when redirect_link not enabled + payload['data']['attributes']['redirect_link_enabled'] = False + del payload['data']['attributes']['redirect_link_label'] + res = app.patch_json_api(url, payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'You must first set redirect_link_enabled to True before specifying a redirect link URL.' + + # Attempting to set redirect_link_label when redirect_link not enabled + payload['data']['attributes']['redirect_link_enabled'] = False + del payload['data']['attributes']['redirect_link_url'] + payload['data']['attributes']['redirect_link_label'] = label + res = app.patch_json_api(url, payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'You must first set redirect_link_enabled to True before specifying a redirect link label.' + + payload['data']['attributes']['redirect_link_enabled'] = False + del payload['data']['attributes']['redirect_link_label'] + res = app.patch_json_api(url, payload, auth=admin_contrib.auth) + assert res.status_code == 200 + forward_addon = project.get_addon('forward') + assert forward_addon is None + assert res.json['data']['attributes']['redirect_link_enabled'] is False + assert res.json['data']['attributes']['redirect_link_url'] is None + assert res.json['data']['attributes']['redirect_link_label'] is None + + def test_redirect_link_label_char_limit(self, app, project, payload, admin_contrib, url): + project.add_addon('forward', ()) + project.save() + + payload['data']['attributes']['redirect_link_label'] = 'a' * 52 + res = app.patch_json_api(url, payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'Ensure this field has no more than 50 characters.' diff --git a/framework/auth/oauth_scopes.py b/framework/auth/oauth_scopes.py index 1d6c7235975..51fc1513ab0 100644 --- a/framework/auth/oauth_scopes.py +++ b/framework/auth/oauth_scopes.py @@ -112,6 +112,9 @@ class CoreScopes(object): NODE_REQUESTS_READ = 'node_requests_read' NODE_REQUESTS_WRITE = 'node_requests_write' + NODE_SETTINGS_READ = 'node_settings_read' + NODE_SETTINGS_WRITE = 'node_settings_write' + PREPRINT_REQUESTS_READ = 'preprint_requests_read' PREPRINT_REQUESTS_WRITE = 'preprint_requests_write' @@ -206,11 +209,11 @@ class ComposedScopes(object): # Privileges relating to who can access a node (via contributors or registrations) NODE_ACCESS_READ = (CoreScopes.NODE_CONTRIBUTORS_READ, CoreScopes.NODE_REGISTRATIONS_READ, CoreScopes.NODE_VIEW_ONLY_LINKS_READ, CoreScopes.REGISTRATION_VIEW_ONLY_LINKS_READ, - CoreScopes.NODE_REQUESTS_READ) + CoreScopes.NODE_REQUESTS_READ, CoreScopes.NODE_SETTINGS_READ) NODE_ACCESS_WRITE = NODE_ACCESS_READ + \ (CoreScopes.NODE_CONTRIBUTORS_WRITE, CoreScopes.NODE_REGISTRATIONS_WRITE, CoreScopes.NODE_VIEW_ONLY_LINKS_WRITE, CoreScopes.REGISTRATION_VIEW_ONLY_LINKS_WRITE, - CoreScopes.NODE_REQUESTS_WRITE) + CoreScopes.NODE_REQUESTS_WRITE, CoreScopes.NODE_SETTINGS_WRITE) # Combine all sets of node permissions into one convenience level NODE_ALL_READ = NODE_METADATA_READ + NODE_DATA_READ + NODE_ACCESS_READ diff --git a/osf/models/node.py b/osf/models/node.py index 0fd6a3b9715..cb9b0d10828 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -2378,6 +2378,34 @@ def update_contributor(self, user, permission, visible, auth, save=False): self.set_visible(user, visible, auth=auth) self.save_node_preprints() + def set_access_requests_enabled(self, access_requests_enabled, auth, save=False): + user = auth.user + if not self.has_permission(user, ADMIN): + raise PermissionsError('Only admins can modify access requests enabled') + self.access_requests_enabled = access_requests_enabled + if self.access_requests_enabled: + self.add_log( + NodeLog.NODE_ACCESS_REQUESTS_ENABLED, + { + 'project': self.parent_id, + 'node': self._id, + 'user': user._id, + }, + auth=auth + ) + else: + self.add_log( + NodeLog.NODE_ACCESS_REQUESTS_DISABLED, + { + 'project': self.parent_id, + 'node': self._id, + 'user': user._id, + }, + auth=auth + ) + if save: + self.save() + def save(self, *args, **kwargs): first_save = not bool(self.pk) if 'old_subjects' in kwargs.keys(): diff --git a/osf_tests/test_node.py b/osf_tests/test_node.py index 4d639c65aa9..7375ab7e7ee 100644 --- a/osf_tests/test_node.py +++ b/osf_tests/test_node.py @@ -3303,6 +3303,22 @@ def test_set_description(self, node, auth): assert latest_log.params['description_original'], old_desc assert latest_log.params['description_new'], 'new description' + def test_set_access_requests(self, node, auth): + assert node.access_requests_enabled is True + node.set_access_requests_enabled(False, auth=auth, save=True) + assert node.access_requests_enabled is False + assert node.logs.latest().action == NodeLog.NODE_ACCESS_REQUESTS_DISABLED + + node.set_access_requests_enabled(True, auth=auth, save=True) + assert node.access_requests_enabled is True + assert node.logs.latest().action == NodeLog.NODE_ACCESS_REQUESTS_ENABLED + + def test_set_access_requests_non_admin(self, node, auth): + contrib = AuthUserFactory() + Contributor.objects.create(user=contrib, node=node, write=True, read=True, visible=True) + with pytest.raises(PermissionsError): + node.set_access_requests_enabled(True, auth=Auth(contrib)) + def test_validate_categories(self): with pytest.raises(ValidationError): Node(category='invalid').save() # an invalid category diff --git a/website/project/views/node.py b/website/project/views/node.py index a6eb8a4f94b..2f73a60ccd3 100644 --- a/website/project/views/node.py +++ b/website/project/views/node.py @@ -398,28 +398,8 @@ def configure_comments(node, **kwargs): @must_not_be_registration def configure_requests(node, **kwargs): access_requests_enabled = request.get_json().get('accessRequestsEnabled') - node.access_requests_enabled = access_requests_enabled - if node.access_requests_enabled: - node.add_log( - NodeLog.NODE_ACCESS_REQUESTS_ENABLED, - { - 'project': node.parent_id, - 'node': node._id, - 'user': kwargs.get('auth').user._id, - }, - auth=kwargs.get('auth', None) - ) - else: - node.add_log( - NodeLog.NODE_ACCESS_REQUESTS_DISABLED, - { - 'project': node.parent_id, - 'node': node._id, - 'user': kwargs.get('auth').user._id, - }, - auth=kwargs.get('auth', None) - ) - node.save() + auth = kwargs.get('auth', None) + node.set_access_requests_enabled(access_requests_enabled, auth, save=True) return {'access_requests_enabled': access_requests_enabled}, 200