diff --git a/api/resources/permissions.py b/api/resources/permissions.py index 3073647a76a..140f2774435 100644 --- a/api/resources/permissions.py +++ b/api/resources/permissions.py @@ -41,6 +41,14 @@ def has_permission(self, request, view): return proxy_object.has_permission(auth.user, self.REQUIRED_PERMISSIONS[request.method]) +class ResourceListPermission(ResourcesPermission, permissions.BasePermission): + '''Permissions for the top-level ResourceList endpoint. + + ResourceList only supports POST + ''' + REQUIRED_PERMISSIONS = {'POST': 'write'} + + class ResourceDetailPermission(ResourcesPermission, permissions.BasePermission): '''Permissions for the top-level ResourcesDetail endpoint. diff --git a/api/resources/serializers.py b/api/resources/serializers.py index b892cb968a7..7bcb5c60056 100644 --- a/api/resources/serializers.py +++ b/api/resources/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers as ser +from api.base.exceptions import Conflict from api.base.serializers import ( EnumField, JSONAPISerializer, @@ -9,7 +10,8 @@ VersionedDateTimeField, ) from api.base.utils import absolute_reverse -from osf.utils.outcomes import ArtifactTypes +from osf.models import Outcome, OutcomeArtifact, Registration +from osf.utils.outcomes import ArtifactTypes, NoPIDError class ResourceSerializer(JSONAPISerializer): @@ -60,3 +62,15 @@ def get_absolute_url(self, obj): 'resource_id': obj._id, }, ) + + def create(self, validated_data): + # Already loaded by view, so no need to error check + guid = self.context['request'].data['registration'] + primary_registration = Registration.load(guid) + + try: + root_outcome = Outcome.objects.for_registration(primary_registration, create=True) + except NoPIDError: + raise Conflict('Cannot add Resources to a Registration that does not have a DOI') + + return OutcomeArtifact.objects.create(outcome=root_outcome) diff --git a/api/resources/urls.py b/api/resources/urls.py index 9a50bedd761..d46f7c1eb7f 100644 --- a/api/resources/urls.py +++ b/api/resources/urls.py @@ -5,6 +5,10 @@ app_name = 'osf' urlpatterns = [ + url( + r'^$', views.ResourceList.as_view(), name=views.ResourceList.view_name, + ), + url( r'^(?P\w+)/$', views.ResourceDetail.as_view(), diff --git a/api/resources/views.py b/api/resources/views.py index 3da4a02a3f3..d7aa2e09825 100644 --- a/api/resources/views.py +++ b/api/resources/views.py @@ -4,18 +4,53 @@ from rest_framework.exceptions import NotFound from api.base import permissions as base_permissions +from api.base.exceptions import JSONAPIException from api.base.parsers import ( JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON, ) from api.base.views import JSONAPIBaseView -from api.resources.permissions import ResourceDetailPermission +from api.resources.permissions import ResourceDetailPermission, ResourceListPermission from api.resources.serializers import ResourceSerializer from framework.auth.oauth_scopes import CoreScopes -from osf.models import Guid, OutcomeArtifact +from osf.models import Guid, OutcomeArtifact, Registration logger = logging.getLogger(__name__) + +class ResourceList(JSONAPIBaseView, generics.ListCreateAPIView): + + permission_classes = ( + ResourceListPermission, + drf_permissions.IsAuthenticatedOrReadOnly, + base_permissions.TokenHasScope, + ) + + required_read_scopes = [CoreScopes.READ_REGISTRATION_RESOURCES] + required_write_scopes = [CoreScopes.WRITE_REGISTRATION_RESOURCES] + + view_category = 'resources' + view_name = 'resource-list' + + serializer_class = ResourceSerializer + + parser_classes = (JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON) + + def get_permissions_proxy(self): + try: + registration_guid = self.request.data['registration'] + except KeyError: + raise JSONAPIException( + detail='Must provide "registration" relationship in payload"', + source={'pointer': '/data/relationships/registration/data/id'}, + ) + + registration = Registration.load(registration_guid) + if not registration: + raise NotFound + return registration + + class ResourceDetail(JSONAPIBaseView, generics.RetrieveUpdateDestroyAPIView): permission_classes = ( diff --git a/api_tests/resources/utils.py b/api_tests/resources/utils.py index c411177a8ae..787d67c62d0 100644 --- a/api_tests/resources/utils.py +++ b/api_tests/resources/utils.py @@ -3,13 +3,13 @@ from api.providers.workflows import Workflows as ModerationWorkflows from api_tests.utils import UserRoles from osf.models import Outcome +from osf.utils.outcomes import ArtifactTypes from osf.utils.workflows import RegistrationModerationStates as RegStates from osf_tests.factories import ( AuthUserFactory, RegistrationFactory, RegistrationProviderFactory ) -from osf.utils.outcomes import ArtifactTypes TEST_EXTERNAL_PID = 'This is a doi' diff --git a/api_tests/resources/views/test_resource_list.py b/api_tests/resources/views/test_resource_list.py new file mode 100644 index 00000000000..ff6796d232b --- /dev/null +++ b/api_tests/resources/views/test_resource_list.py @@ -0,0 +1,191 @@ +import pytest + +from django.utils import timezone + +from api_tests.resources.utils import configure_test_auth +from api_tests.utils import UserRoles +from osf.models import Outcome, OutcomeArtifact +from osf.utils.outcomes import ArtifactTypes +from osf.utils.workflows import RegistrationModerationStates as RegStates +from osf_tests.factories import AuthUserFactory, RegistrationFactory + +POST_URL = '/v2/resources/' + +@pytest.fixture +def admin_user(): + return AuthUserFactory() + + +@pytest.fixture +def registration(admin_user): + registration = RegistrationFactory(creator=admin_user, is_public=True, has_doi=True) + registration.moderation_state = RegStates.ACCEPTED.db_name + registration.save() + return registration + + +@pytest.fixture +def payload(registration): + return { + 'data': { + 'type': 'resources', + 'relationships': { + 'registration': { + 'data': { + 'id': registration._id, + 'type': 'registrations' + } + } + }, + } + } + +@pytest.mark.django_db +class TestResourceListPOSTPermissions: + + @pytest.mark.parametrize('user_role', UserRoles.write_roles()) + def test_status_code__write_user(self, app, registration, payload, user_role): + test_auth = configure_test_auth(registration, user_role) + resp = app.post_json_api(POST_URL, payload, auth=test_auth, expect_errors=True) + assert resp.status_code == 201 + + @pytest.mark.parametrize('user_role', UserRoles.excluding(*UserRoles.write_roles())) + def test_status_code__non_admin(self, app, registration, payload, user_role): + test_auth = configure_test_auth(registration, user_role) + resp = app.post_json_api(POST_URL, payload, auth=test_auth, expect_errors=True) + expected_code = 403 if user_role is not UserRoles.UNAUTHENTICATED else 401 + assert resp.status_code == expected_code + + @pytest.mark.parametrize('user_role', UserRoles) + def test_status_code__withdrawn_registration(self, app, registration, payload, user_role): + registration.moderation_state = RegStates.WITHDRAWN.db_name + registration.save() + + test_auth = configure_test_auth(registration, user_role) + resp = app.post_json_api(POST_URL, payload, auth=test_auth, expect_errors=True) + expected_code = 403 if user_role is not UserRoles.UNAUTHENTICATED else 401 + assert resp.status_code == expected_code + + @pytest.mark.parametrize('user_role', UserRoles) + def test_status_code__deleted_registration(self, app, registration, payload, user_role): + registration.deleted = timezone.now() + registration.save() + + test_auth = configure_test_auth(registration, UserRoles.ADMIN_USER) + resp = app.post_json_api(POST_URL, payload, auth=test_auth, expect_errors=True) + assert resp.status_code == 410 + + +@pytest.mark.django_db +class TestResourceListPOSTBehavior: + + @pytest.fixture + def outcome(self, registration): + return Outcome.objects.for_registration(registration, create=True) + + @pytest.fixture + def alternate_outcome(self, registration): + alternate_outcome = Outcome.objects.for_registration( + registration=RegistrationFactory(has_doi=True) + ) + alternate_outcome.artifact_metadata.create(identifier=registration.get_identifier(category='doi')) + return alternate_outcome + + def test_post_adds_artifact_to_existing_outcome(self, app, outcome, registration, admin_user, payload): + assert outcome.artifact_metadata.count() == 1 + + app.post_json_api(POST_URL, payload, auth=admin_user.auth, expect_errors=True) + + assert outcome.artifact_metadata.count() == 2 + + def test_post_creates_outcome_and_primary_artifact_if_none_exists(self, app, registration, admin_user, payload): + assert not OutcomeArtifact.objects.exists() + assert not Outcome.objects.exists() + + app.post_json_api(POST_URL, payload, auth=admin_user.auth, expect_errors=True) + + created_outcome = Outcome.objects.for_registration(registration, create=False) + assert created_outcome is not None + assert created_outcome.artifact_metadata.count() == 2 + + primary_artifact = created_outcome.artifact_metadata.get(artifact_type=ArtifactTypes.PRIMARY) + assert primary_artifact.identifier == registration.get_identifier(category='doi') + + def test_post_created_artifact_has_default_metadata(self, app, outcome, registration, admin_user, payload): + app.post_json_api(POST_URL, payload, auth=admin_user.auth, expect_errors=True) + + created_artifact = outcome.artifact_metadata.order_by('-created').first() + + assert not created_artifact.title + assert not created_artifact.description + assert not created_artifact.identifier + assert not created_artifact.pid + assert created_artifact.artifact_type == ArtifactTypes.UNDEFINED + assert created_artifact.primary_resource_guid == registration._id + + def test_post_adds_artifact_to_primary_outcome_for_registration( + self, app, outcome, registration, admin_user, payload + ): + # Create an outcome where the Registration is *an* artifact but not the *primary* artifact + alternate_outcome = Outcome.objects.for_registration( + registration=RegistrationFactory(has_doi=True), create=True + ) + alternate_outcome.artifact_metadata.create(identifier=registration.get_identifier(category='doi')) + + assert alternate_outcome.artifact_metadata.count() == 2 + assert outcome.artifact_metadata.count() == 1 + + app.post_json_api(POST_URL, payload, auth=admin_user.auth, expect_errors=True) + + assert alternate_outcome.artifact_metadata.count() == 2 + assert outcome.artifact_metadata.count() == 2 + + def test_post_fails_if_no_registration(self, app): + payload = {'data': {'type': 'resources'}} + resp = app.post_json_api(POST_URL, payload, auth=None, expect_errors=True) + assert resp.status_code == 400 + + def test_post_fails_with_404_if_registration_does_not_exist(self, app): + payload = { + 'data': { + 'type': 'resources', + 'relationships': {'registration': {'data': {'id': 'QWERT', 'type': 'registrations'}}} + } + } + + resp = app.post_json_api(POST_URL, payload, auth=None, expect_errors=True) + assert resp.status_code == 404 + + def test_post_fails_with_409_if_no_registration_identifier(self, app, admin_user, payload): + registration = RegistrationFactory(creator=admin_user, has_doi=False) + payload['data']['relationships']['registration']['data']['id'] = registration._id + + resp = app.post_json_api(POST_URL, payload, auth=admin_user.auth, expect_errors=True) + assert resp.status_code == 409 + +@pytest.mark.django_db +class TestResourceListUnsupportedMethods: + + @pytest.mark.parametrize('user_role', UserRoles) + def test_cannot_GET(self, app, registration, user_role): + test_auth = configure_test_auth(registration, user_role) + resp = app.get(POST_URL, auth=test_auth, expect_errors=True) + assert resp.status_code == 405 + + @pytest.mark.parametrize('user_role', UserRoles) + def test_cannot_PUT(self, app, registration, user_role): + test_auth = configure_test_auth(registration, user_role) + resp = app.put_json_api(POST_URL, auth=test_auth, expect_errors=True) + assert resp.status_code == 405 + + @pytest.mark.parametrize('user_role', UserRoles) + def test_cannot_PATCH(self, app, registration, user_role): + test_auth = configure_test_auth(registration, user_role) + resp = app.patch_json_api(POST_URL, auth=test_auth, expect_errors=True) + assert resp.status_code == 405 + + @pytest.mark.parametrize('user_role', UserRoles) + def test_cannot_DELETE(self, app, registration, user_role): + test_auth = configure_test_auth(registration, user_role) + resp = app.delete_json_api(POST_URL, auth=test_auth, expect_errors=True) + assert resp.status_code == 405 diff --git a/api_tests/utils.py b/api_tests/utils.py index 05878b28044..db1b95cf5d2 100644 --- a/api_tests/utils.py +++ b/api_tests/utils.py @@ -86,6 +86,14 @@ def contributor_roles(cls, include_moderator=False): def noncontributor_roles(cls): return [cls.UNAUTHENTICATED, cls.NONCONTRIB, cls.MODERATOR] + @classmethod + def write_roles(cls): + return [cls.WRITE_USER, cls.ADMIN_USER] + + @classmethod + def excluding(cls, *excluded_roles): + return [role for role in cls if role not in excluded_roles] + def get_permissions_string(self): if self is UserRoles.READ_USER: return 'read'