Skip to content
8 changes: 8 additions & 0 deletions api/resources/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 15 additions & 1 deletion api/resources/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from rest_framework import serializers as ser

from api.base.exceptions import Conflict
from api.base.serializers import (
EnumField,
JSONAPISerializer,
Expand All @@ -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):

Expand Down Expand Up @@ -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)
4 changes: 4 additions & 0 deletions api/resources/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
app_name = 'osf'

urlpatterns = [
url(
r'^$', views.ResourceList.as_view(), name=views.ResourceList.view_name,
),

url(
r'^(?P<resource_id>\w+)/$',
views.ResourceDetail.as_view(),
Expand Down
39 changes: 37 additions & 2 deletions api/resources/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
2 changes: 1 addition & 1 deletion api_tests/resources/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
191 changes: 191 additions & 0 deletions api_tests/resources/views/test_resource_list.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions api_tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down