diff --git a/api/institutions/serializers.py b/api/institutions/serializers.py index 45b9056dd14..5aa4b105576 100644 --- a/api/institutions/serializers.py +++ b/api/institutions/serializers.py @@ -292,3 +292,10 @@ def get_absolute_url(self, obj): 'version': 'v2', }, ) + + +class InstitutionRelated(JSONAPIRelationshipSerializer): + id = ser.CharField(source='_id', required=False, allow_null=True) + class Meta: + type_ = 'institutions' + diff --git a/api/preprints/permissions.py b/api/preprints/permissions.py index 543d6c2a169..d46c0e191af 100644 --- a/api/preprints/permissions.py +++ b/api/preprints/permissions.py @@ -54,6 +54,21 @@ def has_object_permission(self, request, view, obj): return True +class PreprintInstitutionsPermissions(permissions.BasePermission): + + def has_object_permission(self, request, view, obj): + auth = get_user_auth(request) + if obj.is_public and request.method == 'GET': + return True + + if not auth.user: + raise exceptions.NotAuthenticated(detail='User must has no authentication.') + + if not obj.has_permission(auth.user, osf_permissions.WRITE): + raise exceptions.PermissionDenied(detail='User must have admin or write permissions to the preprint.') + return True + + class ContributorDetailPermissions(PreprintPublishedOrAdmin): """Permissions for preprint contributor detail page.""" diff --git a/api/preprints/serializers.py b/api/preprints/serializers.py index 6a00e581f4d..745947860b8 100644 --- a/api/preprints/serializers.py +++ b/api/preprints/serializers.py @@ -35,10 +35,13 @@ NodeTagField, ) from api.base.metrics import MetricsSerializerMixin +from api.base.serializers import BaseAPISerializer +from api.institutions.serializers import InstitutionRelated from api.taxonomies.serializers import TaxonomizableSerializerMixin +from framework.auth import Auth from framework.exceptions import PermissionsError from website.project import signals as project_signals -from osf.exceptions import NodeStateError +from osf.exceptions import NodeStateError, PreprintStateError from osf.models import ( BaseFileNode, Preprint, @@ -48,8 +51,6 @@ ) from osf.utils import permissions as osf_permissions -from osf.exceptions import PreprintStateError - class PrimaryFileRelationshipField(RelationshipField): def get_object(self, file_id): @@ -88,6 +89,7 @@ def to_internal_value(self, license_id): class PreprintSerializer(TaxonomizableSerializerMixin, MetricsSerializerMixin, JSONAPISerializer): filterable_fields = frozenset([ + 'affiliated_institutions', 'id', 'date_created', 'date_modified', @@ -190,6 +192,16 @@ class PreprintSerializer(TaxonomizableSerializerMixin, MetricsSerializerMixin, J related_view_kwargs={'preprint_id': '<_id>'}, )) + affiliated_institutions = RelationshipField( + related_view='preprints:preprint-institutions', + related_view_kwargs={'preprint_id': '<_id>'}, + self_view='preprints:preprint-institutions', + self_view_kwargs={'preprint_id': '<_id>'}, + read_only=False, + required=False, + allow_null=True, + ) + links = LinksField( { 'self': 'get_preprint_url', @@ -530,3 +542,52 @@ def update(self, instance, validated_data): links = LinksField({ 'self': 'get_self_url', }) + + +class PreprintsInstitutionsSerializer(BaseAPISerializer): + id = IDField(read_only=True, source='_id') + name = ser.CharField(read_only=True) + type = ser.SerializerMethodField(read_only=True) + + links = LinksField({ + 'self': 'get_self_url', + 'html': 'get_related_url', + }) + + def get_self_url(self, obj): + return obj.absolute_api_v2_url + + def get_type(self, obj): + return 'institution' + + def get_related_url(self, obj): + return obj.absolute_api_v2_url + 'institutions/' + + class Meta: + type_ = 'institutions' + + def update(self, preprint, validated_data): + user = self.context['request'].user + try: + preprint.update_institutional_affiliation( + Auth(user), + institution_ids=[od['_id'] for od in validated_data['data']] + ) + except ValidationError as e: + raise exceptions.ValidationError(list(e)[0]) + + preprint.save() + + def create(self, validated_data): + preprint = Preprint.load(self.context['view'].kwargs['preprint_id']) + user = self.context['request'].user + data = self.context['request'].data['data'] + try: + preprint.update_institutional_affiliation( + Auth(user), + institution_ids=[od['id'] for od in data] + ) + except ValidationError as e: + raise exceptions.ValidationError(list(e)[0]) + preprint.save() + return preprint diff --git a/api/preprints/urls.py b/api/preprints/urls.py index 70c72d991f6..1df60ca4958 100644 --- a/api/preprints/urls.py +++ b/api/preprints/urls.py @@ -19,4 +19,6 @@ re_path(r'^(?P\w+)/review_actions/$', views.PreprintActionList.as_view(), name=views.PreprintActionList.view_name), re_path(r'^(?P\w+)/requests/$', views.PreprintRequestListCreate.as_view(), name=views.PreprintRequestListCreate.view_name), re_path(r'^(?P\w+)/subjects/$', views.PreprintSubjectsList.as_view(), name=views.PreprintSubjectsList.view_name), + re_path(r'^(?P\w+)/institutions/$', views.PreprintInstitutionsList.as_view(), name=views.PreprintInstitutionsList.view_name), + ] diff --git a/api/preprints/views.py b/api/preprints/views.py index 08df330c7db..6f0c71774a5 100644 --- a/api/preprints/views.py +++ b/api/preprints/views.py @@ -17,12 +17,13 @@ from api.base.views import JSONAPIBaseView, WaterButlerMixin from api.base.filters import ListFilterMixin, PreprintFilterMixin from api.base.parsers import ( - JSONAPIOnetoOneRelationshipParser, - JSONAPIOnetoOneRelationshipParserForRegularJSON, + JSONAPIRelationshipParser, + JSONAPIRelationshipParserForRegularJSON, JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON, + JSONAPIOnetoOneRelationshipParser, + JSONAPIOnetoOneRelationshipParserForRegularJSON ) - from api.base.utils import absolute_reverse, get_user_auth, get_object_or_error from api.base import permissions as base_permissions from api.citations.utils import render_citation @@ -614,3 +615,40 @@ def get_default_queryset(self): def get_queryset(self): return self.get_queryset_from_request() + +from api.preprints.serializers import PreprintsInstitutionsSerializer +from api.preprints.permissions import PreprintInstitutionsPermissions + + +class PreprintInstitutionsList(JSONAPIBaseView, generics.ListCreateAPIView, ListFilterMixin): + """The documentation for this endpoint can be found [here](https://developer.osf.io/#operation/preprint_institutions_list). + """ + permission_classes = ( + drf_permissions.IsAuthenticatedOrReadOnly, + base_permissions.TokenHasScope, + PreprintInstitutionsPermissions + ) + + required_read_scopes = [CoreScopes.NODE_BASE_READ] + required_write_scopes = [CoreScopes.NODE_BASE_WRITE] + serializer_class = PreprintsInstitutionsSerializer + parser_classes = (JSONAPIRelationshipParser, JSONAPIRelationshipParserForRegularJSON, ) + + view_category = 'preprints' + view_name = 'preprint-relationships-institutions' + + def get(self, request, *args, **kwargs): + preprint = Preprint.objects.get(guids___id=self.kwargs['preprint_id']) + self.check_object_permissions(request, preprint) + return self.list(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + preprint = Preprint.objects.get(guids___id=self.kwargs['preprint_id']) + self.check_object_permissions(request, preprint) + return self.create(request, *args, **kwargs) + + def get_default_queryset(self): + return Preprint.objects.get(guids___id=self.kwargs['preprint_id']).affiliated_institutions.all() + + def get_queryset(self): + return self.get_queryset_from_request() \ No newline at end of file diff --git a/api_tests/preprints/views/test_preprint_institutions.py b/api_tests/preprints/views/test_preprint_institutions.py new file mode 100644 index 00000000000..cef9a4e8e44 --- /dev/null +++ b/api_tests/preprints/views/test_preprint_institutions.py @@ -0,0 +1,171 @@ +import pytest + +from api.base.settings.defaults import API_BASE +from osf_tests.factories import ( + PreprintFactory, + AuthUserFactory, + InstitutionFactory, +) + + +@pytest.fixture() +def user(): + return AuthUserFactory() + + +@pytest.fixture() +def admin_with_institutional_affilation(institution): + user = AuthUserFactory() + user.add_or_update_affiliated_institution(institution) + return user + + +@pytest.fixture() +def no_auth_with_institutional_affilation(institution): + user = AuthUserFactory() + user.add_or_update_affiliated_institution(institution) + return user + + +@pytest.fixture() +def admin_without_institutional_affilation(institution, preprint): + user = AuthUserFactory() + preprint.add_permission(user, 'admin') + return user + + +@pytest.fixture() +def institution(): + return InstitutionFactory() + + +@pytest.fixture() +def preprint(admin_with_institutional_affilation): + return PreprintFactory(creator=admin_with_institutional_affilation) + + +@pytest.fixture() +def url(preprint): + return '/{}preprints/{}/institutions/'.format(API_BASE, preprint._id) + + +@pytest.mark.django_db +class TestPreprintInstitutionsList: + + def test_update_affiliated_institutions_add(self, app, user, admin_with_institutional_affilation, admin_without_institutional_affilation, preprint, url, + institution): + update_institutions_payload = { + 'data': [{'type': 'institutions', 'id': institution._id}] + } + + res = app.put_json_api( + url, + update_institutions_payload, + auth=user.auth, + expect_errors=True + ) + assert res.status_code == 403 + + res = app.put_json_api( + url, + update_institutions_payload, + auth=admin_without_institutional_affilation.auth, + expect_errors=True + ) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == f'User is not affiliated with {institution.name},' + + res = app.put_json_api( + url, + update_institutions_payload, + auth=admin_with_institutional_affilation.auth + ) + assert res.status_code == 201 + + preprint.reload() + assert institution in preprint.affiliated_institutions.all() + + log = preprint.logs.latest() + assert log.action == 'affiliated_institution_added' + assert log.params['institution'] == { + 'id': institution._id, + 'name': institution.name + } + + def test_update_affiliated_institutions_remove(self, app, user, admin_with_institutional_affilation, no_auth_with_institutional_affilation, admin_without_institutional_affilation, preprint, url, + institution): + + preprint.affiliated_institutions.add(institution) + preprint.save() + + update_institutions_payload = { + 'data': [] + } + + res = app.put_json_api( + url, + update_institutions_payload, + auth=user.auth, + expect_errors=True + ) + assert res.status_code == 403 + + res = app.put_json_api( + url, + update_institutions_payload, + auth=no_auth_with_institutional_affilation.auth, + expect_errors=True + ) + assert res.status_code == 403 + + + res = app.put_json_api( + url, + update_institutions_payload, + auth=admin_without_institutional_affilation.auth, + expect_errors=True + ) + assert res.status_code == 201 # you can always remove it you are an admin + + res = app.put_json_api( + url, + update_institutions_payload, + auth=admin_with_institutional_affilation.auth + ) + assert res.status_code == 201 + + preprint.reload() + assert institution not in preprint.affiliated_institutions.all() + + log = preprint.logs.latest() + assert log.action == 'affiliated_institution_removed' + assert log.params['institution'] == { + 'id': institution._id, + 'name': institution.name + } + + def test_preprint_institutions_list_get(self, app, user, admin_with_institutional_affilation, preprint, url, + institution): + # For testing purposes + preprint.is_public = False + preprint.save() + + res = app.get(url, expect_errors=True) + assert res.status_code == 401 + + res = app.get(url, auth=user.auth, expect_errors=True) + assert res.status_code == 403 + + res = app.get(url, auth=admin_with_institutional_affilation.auth, expect_errors=True) + assert res.status_code == 200 + + assert res.status_code == 200 + assert not res.json['data'] + + preprint.add_affiliated_institution(institution, admin_with_institutional_affilation) + + res = app.get(url, auth=admin_with_institutional_affilation.auth, expect_errors=True) + assert res.status_code == 200 + + assert res.json['data'][0]['id'] == institution._id + assert res.json['data'][0]['type'] == 'institution' diff --git a/osf/migrations/0021_preprint_affiliated_institutions.py b/osf/migrations/0021_preprint_affiliated_institutions.py new file mode 100644 index 00000000000..968d45ca01f --- /dev/null +++ b/osf/migrations/0021_preprint_affiliated_institutions.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.17 on 2024-06-21 16:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0020_abstractprovider_advertise_on_discover_page'), + ] + + operations = [ + migrations.AddField( + model_name='preprint', + name='affiliated_institutions', + field=models.ManyToManyField(related_name='preprints', to='osf.Institution'), + ), + ] diff --git a/osf/models/mixins.py b/osf/models/mixins.py index b381b14913c..601800a0338 100644 --- a/osf/models/mixins.py +++ b/osf/models/mixins.py @@ -335,6 +335,41 @@ def remove_affiliated_institution(self, inst, user, save=False, log=True): return True return False + def update_institutional_affiliation(self, auth, institution_ids): + user = auth.user + current_institutions = set(self.affiliated_institutions.values_list('_id', flat=True)) + + institutions_to_add = set(institution_ids) - current_institutions + institutions_to_remove = current_institutions - set(institution_ids) + + Institution = apps.get_model('osf.Institution') + + from osf.exceptions import UserNotAffiliatedError + + for institution_id in institutions_to_add: + try: + institution = Institution.objects.get(_id=institution_id) + self.add_affiliated_institution(institution, user, save=False, log=True) + except Institution.DoesNotExist: + raise ValidationError(f'User is not affiliated with {institution.name},' + f' it was not found in records') + except UserNotAffiliatedError: + raise ValidationError(f'User is not affiliated with {institution.name},') + + for institution_id in institutions_to_remove: + try: + institution = Institution.objects.get(_id=institution_id) + self.remove_affiliated_institution(institution, user, save=False, log=True) + except Institution.DoesNotExist: + raise ValidationError(f'User is not affiliated with {institution.name},' + f' it was not found in records') + except UserNotAffiliatedError: + raise ValidationError(f'User is not affiliated with {institution.name},') + + self.save() + + self.update_search() + def is_affiliated_with_institution(self, institution): return self.affiliated_institutions.filter(id=institution.id).exists() diff --git a/osf/models/preprint.py b/osf/models/preprint.py index 428b334c853..91e1f2c04e1 100644 --- a/osf/models/preprint.py +++ b/osf/models/preprint.py @@ -26,7 +26,7 @@ from .provider import PreprintProvider from .preprintlog import PreprintLog from .contributor import PreprintContributor -from .mixins import ReviewableMixin, Taggable, Loggable, GuardianMixin +from .mixins import ReviewableMixin, Taggable, Loggable, GuardianMixin, AffiliatedInstitutionMixin from .validators import validate_doi from osf.utils.fields import NonNaiveDateTimeField from osf.utils.workflows import DefaultStates, ReviewStates @@ -109,7 +109,7 @@ def can_view(self, base_queryset=None, user=None, allow_contribs=True, public_on class Preprint(DirtyFieldsMixin, GuidMixin, IdentifierMixin, ReviewableMixin, BaseModel, TitleMixin, DescriptionMixin, - Loggable, Taggable, ContributorMixin, GuardianMixin, SpamOverrideMixin, TaxonomizableMixin): + Loggable, Taggable, ContributorMixin, GuardianMixin, SpamOverrideMixin, TaxonomizableMixin, AffiliatedInstitutionMixin): objects = PreprintManager() # Preprint fields that trigger a check to the spam filter on save @@ -142,6 +142,9 @@ class Preprint(DirtyFieldsMixin, GuidMixin, IdentifierMixin, ReviewableMixin, Ba ('not_applicable', 'Not applicable') ] + # overrides AffiliatedInstitutionMixin + affiliated_institutions = models.ManyToManyField('Institution', related_name='preprints') + provider = models.ForeignKey('osf.PreprintProvider', on_delete=models.SET_NULL, related_name='preprints', diff --git a/osf/models/preprintlog.py b/osf/models/preprintlog.py index ef5902935d5..3411961a674 100644 --- a/osf/models/preprintlog.py +++ b/osf/models/preprintlog.py @@ -57,6 +57,8 @@ class PreprintLog(ObjectIDMixin, BaseModel): CONFIRM_HAM = 'confirm_ham' FLAG_SPAM = 'flag_spam' CONFIRM_SPAM = 'confirm_spam' + AFFILIATED_INSTITUTION_ADDED = 'affiliated_institution_added' + AFFILIATED_INSTITUTION_REMOVED = 'affiliated_institution_removed' actions = ([ DELETED,