diff --git a/api/users/serializers.py b/api/users/serializers.py index 6dcc5a75d6f..dd1507c91bc 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -254,6 +254,29 @@ class Meta: type_ = 'institutions' +class UserIdentitiesSerializer(JSONAPISerializer): + id = IDField(source='_id', read_only=True) + type = TypeField() + external_id = ser.CharField(read_only=True) + status = ser.CharField(read_only=True) + + links = LinksField({ + 'self': 'get_absolute_url', + }) + + def get_absolute_url(self, obj): + return absolute_reverse( + 'users:user-identities-detail', + kwargs={ + 'user_id': self.context['request'].parser_context['kwargs']['user_id'], + 'version': self.context['request'].parser_context['kwargs']['version'], + 'identity_id': obj['_id'] + } + ) + + class Meta: + type_ = 'external-identities' + class UserAccountExportSerializer(BaseAPISerializer): type = TypeField() diff --git a/api/users/urls.py b/api/users/urls.py index 72a9c87c7c3..6fb27dfd4c1 100644 --- a/api/users/urls.py +++ b/api/users/urls.py @@ -16,6 +16,8 @@ url(r'^(?P\w+)/registrations/$', views.UserRegistrations.as_view(), name=views.UserRegistrations.view_name), url(r'^(?P\w+)/quickfiles/$', views.UserQuickFiles.as_view(), name=views.UserQuickFiles.view_name), url(r'^(?P\w+)/relationships/institutions/$', views.UserInstitutionsRelationship.as_view(), name=views.UserInstitutionsRelationship.view_name), + url(r'^(?P\w+)/settings/identities/$', views.UserIdentitiesList.as_view(), name=views.UserIdentitiesList.view_name), + url(r'^(?P\w+)/settings/identities/(?P\w+)/$', views.UserIdentitiesDetail.as_view(), name=views.UserIdentitiesDetail.view_name), url(r'^(?P\w+)/settings/export/$', views.UserAccountExport.as_view(), name=views.UserAccountExport.view_name), url(r'^(?P\w+)/settings/deactivate/$', views.UserAccountDeactivate.as_view(), name=views.UserAccountDeactivate.view_name), ] diff --git a/api/users/views.py b/api/users/views.py index c8100cfb2ae..6985e2510fd 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -22,6 +22,7 @@ ReadOnlyOrCurrentUserRelationship) from api.users.serializers import (UserAddonSettingsSerializer, UserDetailSerializer, + UserIdentitiesSerializer, UserInstitutionsRelationshipSerializer, UserSerializer, UserQuickFilesSerializer, @@ -445,6 +446,73 @@ def perform_destroy(self, instance): user.save() +class UserIdentitiesList(JSONAPIBaseView, generics.ListAPIView, UserMixin): + """ + The documentation for this endpoint can be found [here](https://developer.osf.io/#operation/external_identities_list). + """ + permission_classes = ( + base_permissions.TokenHasScope, + drf_permissions.IsAuthenticatedOrReadOnly, + CurrentUser, + ) + + serializer_class = UserIdentitiesSerializer + + required_read_scopes = [CoreScopes.USER_SETTINGS_READ] + required_write_scopes = [CoreScopes.NULL] + + view_category = 'users' + view_name = 'user-identities-list' + + # overrides ListAPIView + def get_queryset(self): + user = self.get_user() + identities = [] + for key, value in user.external_identity.iteritems(): + identities.append({'_id': key, 'external_id': value.keys()[0], 'status': value.values()[0]}) + + return identities + + +class UserIdentitiesDetail(JSONAPIBaseView, generics.RetrieveDestroyAPIView, UserMixin): + """ + The documentation for this endpoint can be found [here](https://developer.osf.io/#operation/external_identities_detail). + """ + permission_classes = ( + base_permissions.TokenHasScope, + drf_permissions.IsAuthenticatedOrReadOnly, + CurrentUser, + ) + + required_read_scopes = [CoreScopes.USER_SETTINGS_READ] + required_write_scopes = [CoreScopes.USER_SETTINGS_WRITE] + + serializer_class = UserIdentitiesSerializer + + view_category = 'users' + view_name = 'user-identities-detail' + + def get_object(self): + user = self.get_user() + identity_id = self.kwargs['identity_id'] + try: + identity = user.external_identity[identity_id] + except KeyError: + raise NotFound('Requested external identity could not be found.') + + return {'_id': identity_id, 'external_id': identity.keys()[0], 'status': identity.values()[0]} + + def perform_destroy(self, instance): + user = self.get_user() + identity_id = self.kwargs['identity_id'] + try: + user.external_identity.pop(identity_id) + except KeyError: + raise NotFound('Requested external identity could not be found.') + + user.save() + + class UserAccountExport(JSONAPIBaseView, generics.CreateAPIView, UserMixin): permission_classes = ( drf_permissions.IsAuthenticatedOrReadOnly, diff --git a/api_tests/users/views/test_user_external_identities.py b/api_tests/users/views/test_user_external_identities.py new file mode 100644 index 00000000000..034ead0705c --- /dev/null +++ b/api_tests/users/views/test_user_external_identities.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +import pytest + +from osf_tests.factories import AuthUserFactory +from api.base.settings.defaults import API_BASE + + +@pytest.fixture() +def user(): + user = AuthUserFactory() + user.external_identity = { + 'ORCID': { + '0000-0001-9143-4653': 'VERIFIED' + }, + 'LOTUS': { + '0000-0001-9143-4652': 'LINK' + } + } + user.save() + return user + + +@pytest.fixture() +def unauthorized_user(): + return AuthUserFactory() + + +@pytest.mark.django_db +class TestUserIdentitiesList: + + @pytest.fixture() + def url(self, user): + return '/{}users/{}/settings/identities/'.format(API_BASE, user._id) + + def test_authorized_gets_200(self, app, user, url): + res = app.get(url, auth=user.auth) + assert res.status_code == 200 + assert res.content_type == 'application/vnd.api+json' + assert res.json['data'][0]['attributes']['status'] == 'LINK' + assert res.json['data'][0]['attributes']['external_id'] == '0000-0001-9143-4652' + assert res.json['data'][0]['type'] == 'external-identities' + assert res.json['data'][0]['id'] == 'LOTUS' + + assert res.json['data'][1]['attributes']['status'] == 'VERIFIED' + assert res.json['data'][1]['attributes']['external_id'] == '0000-0001-9143-4653' + assert res.json['data'][1]['type'] == 'external-identities' + assert res.json['data'][1]['id'] == 'ORCID' + + def test_anonymous_gets_401(self, app, url): + res = app.get(url, expect_errors=True) + assert res.status_code == 401 + assert res.content_type == 'application/vnd.api+json' + + def test_unauthorized_gets_403(self, app, url, unauthorized_user): + res = app.get(url, auth=unauthorized_user.auth, expect_errors=True) + assert res.status_code == 403 + assert res.content_type == 'application/vnd.api+json' + + +@pytest.mark.django_db +class TestUserIdentitiesDetail: + + @pytest.fixture() + def url(self, user): + return '/{}users/{}/settings/identities/ORCID/'.format(API_BASE, user._id) + + @pytest.fixture() + def bad_url(self, user): + return '/{}users/{}/settings/identities/404-CID/'.format(API_BASE, user._id) + + def test_authorized_gets_200(self, app, user, url): + res = app.get(url, auth=user.auth) + assert res.status_code == 200 + assert res.content_type == 'application/vnd.api+json' + assert res.json['data']['attributes']['status'] == 'VERIFIED' + assert res.json['data']['attributes']['external_id'] == '0000-0001-9143-4653' + assert res.json['data']['type'] == 'external-identities' + assert '/v2/users/{}/settings/identities/ORCID/'.format(user._id) in res.json['data']['links']['self'] + + def test_delete_204(self, app, user, url): + res = app.delete(url, auth=user.auth) + assert res.status_code == 204 + + user.refresh_from_db() + assert user.external_identity == { + 'LOTUS': { + '0000-0001-9143-4652': 'LINK' + } + } + + def test_anonymous_gets_401(self, app, url): + res = app.get(url, expect_errors=True) + assert res.status_code == 401 + assert res.content_type == 'application/vnd.api+json' + + def test_unauthorized_delete_403(self, app, url, unauthorized_user): + res = app.get(url, auth=unauthorized_user.auth, expect_errors=True) + assert res.status_code == 403 + assert res.content_type == 'application/vnd.api+json' + + def test_bad_request_gets_404(self, app, bad_url): + res = app.get(bad_url, expect_errors=True) + assert res.status_code == 404 + assert res.content_type == 'application/vnd.api+json' + + def test_patch_405(self, app, user, url): + res = app.patch(url, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.content_type == 'application/vnd.api+json'