diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 45636819f72..2713745261d 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -299,12 +299,12 @@ class WriteOrPublicForRelationshipInstitutions(permissions.BasePermission): def has_object_permission(self, request, view, obj): assert isinstance(obj, dict) auth = get_user_auth(request) - node = obj['self'] + resource = obj['self'] if request.method in permissions.SAFE_METHODS: - return node.is_public or node.can_view(auth) + return resource.is_public or resource.can_view(auth) else: - return node.has_permission(auth.user, osf_permissions.WRITE) + return resource.has_permission(auth.user, osf_permissions.WRITE) class ReadOnlyIfRegistration(permissions.BasePermission): diff --git a/api/preprints/permissions.py b/api/preprints/permissions.py index 543d6c2a169..8bfa52e5b22 100644 --- a/api/preprints/permissions.py +++ b/api/preprints/permissions.py @@ -138,3 +138,18 @@ def has_object_permission(self, request, view, obj): raise exceptions.PermissionDenied(detail='Withdrawn preprints may not be edited') return True raise exceptions.NotFound + + +class PreprintInstitutionPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + if obj.is_public: + return True + + auth = get_user_auth(request) + if not auth.user: + return False + + if request.method in permissions.SAFE_METHODS: + return obj.has_permission(auth.user, 'read') + else: + return obj.has_permission(auth.user, 'write') diff --git a/api/preprints/serializers.py b/api/preprints/serializers.py index 6a00e581f4d..2db0e385fea 100644 --- a/api/preprints/serializers.py +++ b/api/preprints/serializers.py @@ -7,6 +7,7 @@ from api.base.exceptions import Conflict, JSONAPIException from api.base.serializers import ( + BaseAPISerializer, JSONAPISerializer, IDField, TypeField, @@ -20,6 +21,7 @@ WaterbutlerLink, HideIfPreprint, LinkedNodesRelationshipSerializer, + JSONAPIRelationshipSerializer, ) from api.base.utils import absolute_reverse, get_user_auth from api.base.parsers import NO_DATA_ERROR @@ -33,12 +35,14 @@ NodeContributorDetailSerializer, get_license_details, NodeTagField, + update_institutions, + ) from api.base.metrics import MetricsSerializerMixin from api.taxonomies.serializers import TaxonomizableSerializerMixin 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 +52,6 @@ ) from osf.utils import permissions as osf_permissions -from osf.exceptions import PreprintStateError - class PrimaryFileRelationshipField(RelationshipField): def get_object(self, file_id): @@ -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,51 @@ def update(self, instance, validated_data): links = LinksField({ 'self': 'get_self_url', }) + + +class InstitutionRelated(JSONAPIRelationshipSerializer): + id = ser.CharField(source='_id', required=False, allow_null=True) + + class Meta: + type_ = 'institutions' + + +class PreprintsInstitutionsRelationshipSerializer(BaseAPISerializer): + data = ser.ListField(child=InstitutionRelated()) + + links = LinksField({ + 'self': 'get_self_url', + 'html': 'get_related_url', + }) + + def get_self_url(self, obj): + return obj['self'].absolute_api_v2_url + + def get_related_url(self, obj): + return f"{obj['self'].absolute_api_v2_url}institutions/" + + class Meta: + type_ = 'institutions' + + def make_instance_obj(self, obj): + return { + 'data': obj.affiliated_institutions.all(), + 'self': obj, + } + + def update(self, instance, validated_data): + preprint = instance['self'] + user = self.context['request'].user + update_institutions(preprint, validated_data['data'], user) + preprint.save() + + return self.make_instance_obj(preprint) + + def create(self, validated_data): + instance = self.context['view'].get_object() + user = self.context['request'].user + preprint = instance['self'] + update_institutions(preprint, validated_data['data'], user, post=True) + preprint.save() + + return self.make_instance_obj(preprint) diff --git a/api/preprints/urls.py b/api/preprints/urls.py index 70c72d991f6..f6fb1b02f6a 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), + re_path(r'^(?P\w+)/relationships/institutions/$', views.PreprintInstitutionsRelationshipList.as_view(), name=views.PreprintInstitutionsRelationshipList.view_name), ] diff --git a/api/preprints/views.py b/api/preprints/views.py index 08df330c7db..982d37e5873 100644 --- a/api/preprints/views.py +++ b/api/preprints/views.py @@ -6,7 +6,12 @@ from rest_framework import permissions as drf_permissions from framework.auth.oauth_scopes import CoreScopes -from osf.models import ReviewAction, Preprint, PreprintContributor +from osf.models import ( + ReviewAction, + Preprint, + PreprintContributor, + Institution, +) from osf.utils.requests import check_select_for_update from api.actions.permissions import ReviewActionPermission @@ -17,12 +22,11 @@ from api.base.views import JSONAPIBaseView, WaterButlerMixin from api.base.filters import ListFilterMixin, PreprintFilterMixin from api.base.parsers import ( - JSONAPIOnetoOneRelationshipParser, - JSONAPIOnetoOneRelationshipParserForRegularJSON, 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 @@ -41,6 +45,7 @@ NodeCitationStyleSerializer, ) + from api.identifiers.views import IdentifierList from api.identifiers.serializers import PreprintIdentifierSerializer from api.nodes.views import NodeMixin, NodeContributorsList, NodeContributorDetail, NodeFilesList, NodeStorageProvidersList, NodeStorageProvider @@ -51,6 +56,7 @@ AdminOrPublic, ContributorDetailPermissions, PreprintFilesPermissions, + PreprintInstitutionPermission, ) from api.nodes.permissions import ( ContributorOrPublic, @@ -61,6 +67,12 @@ from api.subjects.views import BaseResourceSubjectsList from api.base.metrics import PreprintMetricsViewMixin from osf.metrics import PreprintDownload, PreprintView +from api.institutions.serializers import InstitutionSerializer +from api.base.parsers import JSONAPIRelationshipParser +from api.base.parsers import JSONAPIRelationshipParserForRegularJSON +from api.preprints.serializers import PreprintsInstitutionsRelationshipSerializer +from api.nodes.permissions import WriteOrPublicForRelationshipInstitutions + class PreprintMixin(NodeMixin): serializer_class = PreprintSerializer @@ -614,3 +626,71 @@ def get_default_queryset(self): def get_queryset(self): return self.get_queryset_from_request() + + +class PreprintInstitutionsList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin, PreprintMixin): + """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, + PreprintInstitutionPermission, + ) + + required_read_scopes = [CoreScopes.PREPRINTS_READ, CoreScopes.INSTITUTION_READ] + required_write_scopes = [CoreScopes.NULL] + serializer_class = InstitutionSerializer + + model = Institution + view_category = 'preprints' + view_name = 'preprint-institutions' + + def get_resource(self): + return self.get_preprint() + + def get_queryset(self): + return self.get_resource().affiliated_institutions.all() + + +class PreprintInstitutionsRelationshipList(JSONAPIBaseView, generics.RetrieveUpdateDestroyAPIView, generics.CreateAPIView, PreprintMixin): + """ """ + permission_classes = ( + drf_permissions.IsAuthenticatedOrReadOnly, + base_permissions.TokenHasScope, + WriteOrPublicForRelationshipInstitutions, + ) + required_read_scopes = [CoreScopes.PREPRINTS_READ] + required_write_scopes = [CoreScopes.PREPRINTS_WRITE] + serializer_class = PreprintsInstitutionsRelationshipSerializer + parser_classes = (JSONAPIRelationshipParser, JSONAPIRelationshipParserForRegularJSON, ) + + view_category = 'preprints' + view_name = 'preprint-relationships-institutions' + + def get_resource(self): + return self.get_preprint(check_object_permissions=False) + + def get_object(self): + preprint = self.get_resource() + obj = { + 'data': preprint.affiliated_institutions.all(), + 'self': preprint, + } + self.check_object_permissions(self.request, obj) + return obj + + def perform_destroy(self, instance): + data = self.request.data['data'] + user = self.request.user + current_insts = {inst._id: inst for inst in instance['data']} + node = instance['self'] + + for val in data: + if val['id'] in current_insts: + if not user.is_affiliated_with_institution(current_insts[val['id']]) and not node.has_permission(user, 'admin'): + raise PermissionDenied + node.remove_affiliated_institution(inst=current_insts[val['id']], user=user) + node.save() + + def create(self, *args, **kwargs): + return super().create(*args, **kwargs) 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..c58e40bbaf2 --- /dev/null +++ b/api_tests/preprints/views/test_preprint_institutions.py @@ -0,0 +1,147 @@ +import pytest + +from api.base.settings.defaults import API_BASE +from osf_tests.factories import ( + PreprintFactory, + AuthUserFactory, + InstitutionFactory, +) + + +@pytest.mark.django_db +class TestPrivatePreprintInstitutionsList: + + @pytest.fixture() + def url(self, private_preprint): + return f'/{API_BASE}preprints/{private_preprint._id}/institutions/' + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def private_preprint(self): + preprint = PreprintFactory() + preprint.is_public = False + preprint.save() + return preprint + + @pytest.fixture() + def read_contrib(self, private_preprint): + user = AuthUserFactory() + private_preprint.add_permission(user, 'read') + return user + + @pytest.fixture() + def write_contrib(self, private_preprint): + user = AuthUserFactory() + private_preprint.add_permission(user, 'write') + return user + + @pytest.fixture() + def admin_contrib(self, private_preprint): + user = AuthUserFactory() + private_preprint.add_permission(user, 'admin') + return user + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + def test_preprint_institutions_no_auth(self, app, url): + res = app.get(url, expect_errors=True) + assert res.status_code == 401 + + def test_preprint_institutions_unauth(self, app, url, user, private_preprint): + res = app.get(url, auth=user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_preprint_institutions_read(self, app, url, read_contrib, private_preprint, institution): + + res = app.get(url, auth=read_contrib.auth) + assert res.status_code == 200 + assert not res.json['data'] + + private_preprint.affiliated_institutions.add(institution) + res = app.get(url, auth=read_contrib.auth) + assert res.status_code == 200 + + assert res.json['data'][0]['id'] == institution._id + assert res.json['data'][0]['type'] == 'institutions' + + def test_preprint_institutions_write(self, app, url, write_contrib, private_preprint, institution): + + res = app.get(url, auth=write_contrib.auth) + assert res.status_code == 200 + + assert res.status_code == 200 + assert not res.json['data'] + + private_preprint.affiliated_institutions.add(institution) + res = app.get(url, auth=write_contrib.auth) + assert res.status_code == 200 + + assert res.json['data'][0]['id'] == institution._id + assert res.json['data'][0]['type'] == 'institutions' + + def test_preprint_institutions_admin(self, app, url, admin_contrib, private_preprint, institution): + + res = app.get(url, auth=admin_contrib.auth) + assert res.status_code == 200 + + assert res.status_code == 200 + assert not res.json['data'] + + private_preprint.affiliated_institutions.add(institution) + res = app.get(url, auth=admin_contrib.auth) + assert res.status_code == 200 + + assert res.json['data'][0]['id'] == institution._id + assert res.json['data'][0]['type'] == 'institutions' + + +@pytest.mark.django_db +class TestPublicPreprintInstitutionsList: + + @pytest.fixture() + def url(self, public_preprint): + return f'/{API_BASE}preprints/{public_preprint._id}/institutions/' + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + @pytest.fixture() + def public_preprint(self): + return PreprintFactory() + + @pytest.fixture() + def read_contrib(self, public_preprint): + user = AuthUserFactory() + public_preprint.add_permission(user, 'read') + return user + + def test_preprint_institutions_no_auth(self, app, url): + res = app.get(url) + assert res.status_code == 200 + + def test_preprint_institutions_unauth(self, app, url, user): + res = app.get(url, auth=user.auth) + assert res.status_code == 200 + + def test_preprint_institutions_read(self, app, url, read_contrib, public_preprint, institution): + + res = app.get(url, auth=read_contrib.auth) + assert res.status_code == 200 + assert not res.json['data'] + + public_preprint.affiliated_institutions.add(institution) + res = app.get(url, auth=read_contrib.auth) + assert res.status_code == 200 + + assert res.json['data'][0]['id'] == institution._id + assert res.json['data'][0]['type'] == 'institutions' diff --git a/api_tests/preprints/views/test_preprint_institutions_relationship.py b/api_tests/preprints/views/test_preprint_institutions_relationship.py new file mode 100644 index 00000000000..026f753b2f5 --- /dev/null +++ b/api_tests/preprints/views/test_preprint_institutions_relationship.py @@ -0,0 +1,163 @@ +import pytest + +from api.base.settings.defaults import API_BASE +from osf_tests.factories import ( + PreprintFactory, + AuthUserFactory, + InstitutionFactory, +) + + +@pytest.mark.django_db +class TestPreprintInstitutionsList: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def admin_with_institutional_affilation(self, institution, preprint): + user = AuthUserFactory() + preprint.add_permission(user, 'admin') + user.add_or_update_affiliated_institution(institution) + return user + + @pytest.fixture() + def no_auth_with_institutional_affilation(self, institution): + user = AuthUserFactory() + user.add_or_update_affiliated_institution(institution) + return user + + @pytest.fixture() + def admin_without_institutional_affilation(self, institution, preprint): + user = AuthUserFactory() + preprint.add_permission(user, 'admin') + return user + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + @pytest.fixture() + def preprint(self): + return PreprintFactory() + + @pytest.fixture() + def url(self, preprint): + return f'/{API_BASE}preprints/{preprint._id}/relationships/institutions/' + + 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 == 403 + assert res.json['errors'][0]['detail'] == f'User needs to be affiliated with {institution.name}' + + res = app.put_json_api( + url, + update_institutions_payload, + auth=admin_with_institutional_affilation.auth + ) + assert res.status_code == 200 + + 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 == 200 # 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 == 200 + + 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, admin_without_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_without_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) + assert res.status_code == 200 + + assert res.json['data'][0]['id'] == institution._id + assert res.json['data'][0]['type'] == 'institutions' 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,