diff --git a/api/base/permissions.py b/api/base/permissions.py index 543d041076c..42f09547b6c 100644 --- a/api/base/permissions.py +++ b/api/base/permissions.py @@ -9,7 +9,9 @@ from framework.auth.cas import CasResponse from osf.models import ApiOAuth2Application, ApiOAuth2PersonalToken +from osf.utils import permissions as osf_permissions from website.util.sanitize import is_iterable_but_not_string +from api.base.utils import get_user_auth # Implementation built on django-oauth-toolkit, but with more granular control over read+write permissions @@ -158,3 +160,15 @@ def has_object_permission(self, request, view, obj): obj = self.get_object(request, view, obj) return super(Perm, self).has_object_permission(request, view, obj) return Perm + + +class WriteOrPublicForRelationshipInstitutions(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + assert isinstance(obj, dict) + auth = get_user_auth(request) + resource = obj['self'] + + if request.method in permissions.SAFE_METHODS: + return resource.is_public or resource.can_view(auth) + else: + return resource.has_permission(auth.user, osf_permissions.WRITE) diff --git a/api/draft_registrations/serializers.py b/api/draft_registrations/serializers.py index ddae8794e0c..093c0492b5c 100644 --- a/api/draft_registrations/serializers.py +++ b/api/draft_registrations/serializers.py @@ -8,7 +8,6 @@ from api.nodes.serializers import ( DraftRegistrationLegacySerializer, DraftRegistrationDetailLegacySerializer, - update_institutions, get_license_details, NodeSerializer, NodeLicenseSerializer, @@ -18,6 +17,7 @@ NodeContributorDetailSerializer, RegistrationSchemaRelationshipField, ) +from api.institutions.utils import update_institutions from api.taxonomies.serializers import TaxonomizableSerializerMixin from osf.exceptions import DraftRegistrationStateError from osf.models import Node diff --git a/api/institutions/serializers.py b/api/institutions/serializers.py index 04f4842391b..c64d52a74aa 100644 --- a/api/institutions/serializers.py +++ b/api/institutions/serializers.py @@ -292,3 +292,9 @@ 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/institutions/utils.py b/api/institutions/utils.py new file mode 100644 index 00000000000..394bb9caf10 --- /dev/null +++ b/api/institutions/utils.py @@ -0,0 +1,58 @@ +from rest_framework import exceptions + +from api.base.serializers import relationship_diff +from osf.models import Institution +from osf.utils import permissions as osf_permissions + + +def get_institutions_to_add_remove(institutions, new_institutions): + diff = relationship_diff( + current_items={inst._id: inst for inst in institutions.all()}, + new_items={inst['_id']: inst for inst in new_institutions}, + ) + + insts_to_add = [] + for inst_id in diff['add']: + inst = Institution.load(inst_id) + if not inst: + raise exceptions.NotFound(detail=f'Institution with id "{inst_id}" was not found') + insts_to_add.append(inst) + + return insts_to_add, diff['remove'].values() + + +def update_institutions(resource, new_institutions, user, post=False): + add, remove = get_institutions_to_add_remove( + institutions=resource.affiliated_institutions, + new_institutions=new_institutions, + ) + + if not post: + for inst in remove: + if not user.is_affiliated_with_institution(inst) and not resource.has_permission(user, osf_permissions.ADMIN): + raise exceptions.PermissionDenied(detail=f'User needs to be affiliated with {inst.name}') + resource.remove_affiliated_institution(inst, user) + + for inst in add: + if not user.is_affiliated_with_institution(inst): + raise exceptions.PermissionDenied(detail=f'User needs to be affiliated with {inst.name}',) + resource.add_affiliated_institution(inst, user) + + +def update_institutions_if_user_associated(resource, desired_institutions_data, user): + """Update institutions only if the user is associated with the institutions. Otherwise, raise an exception.""" + + desired_institutions = Institution.objects.filter(_id__in=[item['_id'] for item in desired_institutions_data]) + + # If a user wants to affiliate with a resource check that they have it. + for inst in desired_institutions: + if user.is_affiliated_with_institution(inst): + resource.add_affiliated_institution(inst, user) + else: + raise exceptions.PermissionDenied(detail=f'User needs to be affiliated with {inst.name}') + + # If a user doesn't include an affiliation they have, then remove it. + resource_institutions = resource.affiliated_institutions.all() + for inst in user.get_affiliated_institutions(): + if inst in resource_institutions and inst not in desired_institutions: + resource.remove_affiliated_institution(inst, user) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 45636819f72..575de0727fc 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -295,18 +295,6 @@ def has_object_permission(self, request, view, obj): return True -class WriteOrPublicForRelationshipInstitutions(permissions.BasePermission): - def has_object_permission(self, request, view, obj): - assert isinstance(obj, dict) - auth = get_user_auth(request) - node = obj['self'] - - if request.method in permissions.SAFE_METHODS: - return node.is_public or node.can_view(auth) - else: - return node.has_permission(auth.user, osf_permissions.WRITE) - - class ReadOnlyIfRegistration(permissions.BasePermission): """Makes PUT and POST forbidden for registrations.""" diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index 99d9f8abc94..a698062bd90 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -7,11 +7,10 @@ ) from api.base.serializers import ( VersionedDateTimeField, HideIfRegistration, IDField, - JSONAPIRelationshipSerializer, JSONAPISerializer, LinksField, NodeFileHyperLinkField, RelationshipField, ShowIfVersion, TargetTypeField, TypeField, - WaterbutlerLink, relationship_diff, BaseAPISerializer, + WaterbutlerLink, BaseAPISerializer, HideIfWikiDisabled, ShowIfAdminScopeOrAnonymous, ValuesListField, TargetField, ) @@ -21,6 +20,7 @@ get_user_auth, is_truthy, ) from api.base.versioning import get_kebab_snake_case_field +from api.institutions.utils import update_institutions from api.taxonomies.serializers import TaxonomizableSerializerMixin from django.apps import apps from django.conf import settings @@ -34,7 +34,7 @@ from addons.osfstorage.models import Region from osf.exceptions import NodeStateError from osf.models import ( - Comment, DraftRegistration, ExternalAccount, Institution, + Comment, DraftRegistration, ExternalAccount, RegistrationSchema, AbstractNode, PrivateLink, Preprint, RegistrationProvider, OSFGroup, NodeLicense, DraftNode, Registration, Node, @@ -52,44 +52,6 @@ def to_internal_value(self, data): return self.get_object(data) -def get_institutions_to_add_remove(institutions, new_institutions): - diff = relationship_diff( - current_items={inst._id: inst for inst in institutions.all()}, - new_items={inst['_id']: inst for inst in new_institutions}, - ) - - insts_to_add = [] - for inst_id in diff['add']: - inst = Institution.load(inst_id) - if not inst: - raise exceptions.NotFound(detail='Institution with id "{}" was not found'.format(inst_id)) - insts_to_add.append(inst) - - return insts_to_add, diff['remove'].values() - - -def update_institutions(node, new_institutions, user, post=False): - add, remove = get_institutions_to_add_remove( - institutions=node.affiliated_institutions, - new_institutions=new_institutions, - ) - - if not post: - for inst in remove: - if not user.is_affiliated_with_institution(inst) and not node.has_permission(user, osf_permissions.ADMIN): - raise exceptions.PermissionDenied( - detail='User needs to be affiliated with {}'.format(inst.name), - ) - node.remove_affiliated_institution(inst, user) - - for inst in add: - if not user.is_affiliated_with_institution(inst): - raise exceptions.PermissionDenied( - detail='User needs to be affiliated with {}'.format(inst.name), - ) - node.add_affiliated_institution(inst, user) - - class RegionRelationshipField(RelationshipField): def to_internal_value(self, data): @@ -1467,13 +1429,10 @@ def get_storage_addons_url(self, obj): }, ) -class InstitutionRelated(JSONAPIRelationshipSerializer): - id = ser.CharField(source='_id', required=False, allow_null=True) - class Meta: - type_ = 'institutions' - class NodeInstitutionsRelationshipSerializer(BaseAPISerializer): + from api.institutions.serializers import InstitutionRelated # Avoid circular import + data = ser.ListField(child=InstitutionRelated()) links = LinksField({ 'self': 'get_self_url', diff --git a/api/nodes/views.py b/api/nodes/views.py index 23777b677b9..5bf1313c3b9 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -57,6 +57,7 @@ WaterButlerMixin, ) from api.base.waffle_decorators import require_flag +from api.base.permissions import WriteOrPublicForRelationshipInstitutions from api.cedar_metadata_records.serializers import CedarMetadataRecordsListSerializer from api.cedar_metadata_records.utils import can_view_record from api.citations.utils import render_citation @@ -87,7 +88,6 @@ NodeGroupDetailPermissions, IsContributorOrGroupMember, AdminDeletePermissions, - WriteOrPublicForRelationshipInstitutions, ExcludeWithdrawals, NodeLinksShowIfVersion, ReadOnlyIfWithdrawn, diff --git a/api/preprints/serializers.py b/api/preprints/serializers.py index 6a00e581f4d..3b7981a1e24 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, @@ -35,10 +36,11 @@ NodeTagField, ) from api.base.metrics import MetricsSerializerMixin +from api.institutions.utils import update_institutions_if_user_associated 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 +50,6 @@ ) from osf.utils import permissions as osf_permissions -from osf.exceptions import PreprintStateError - class PrimaryFileRelationshipField(RelationshipField): def get_object(self, file_id): @@ -530,3 +530,43 @@ def update(self, instance, validated_data): links = LinksField({ 'self': 'get_self_url', }) + + +class PreprintsInstitutionsRelationshipSerializer(BaseAPISerializer): + from api.institutions.serializers import InstitutionRelated # Avoid circular import + 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_if_user_associated(preprint, validated_data['data'], user) + preprint.save() + return self.make_instance_obj(preprint) + + def create(self, validated_data): + instance = self.context['view'].get_object() + preprint = instance['self'] + user = self.context['request'].user + update_institutions_if_user_associated(preprint, validated_data['data'], user) + preprint.save() + return self.make_instance_obj(preprint) diff --git a/api/preprints/urls.py b/api/preprints/urls.py index e243f89cf73..dede806955c 100644 --- a/api/preprints/urls.py +++ b/api/preprints/urls.py @@ -20,4 +20,5 @@ 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.PreprintInstitutionsRelationship.as_view(), name=views.PreprintInstitutionsRelationship.view_name), ] diff --git a/api/preprints/views.py b/api/preprints/views.py index 7abc07b9cb0..4ba40ed1ff7 100644 --- a/api/preprints/views.py +++ b/api/preprints/views.py @@ -26,6 +26,8 @@ JSONAPIMultipleRelationshipsParserForRegularJSON, JSONAPIOnetoOneRelationshipParser, JSONAPIOnetoOneRelationshipParserForRegularJSON, + JSONAPIRelationshipParser, + JSONAPIRelationshipParserForRegularJSON, ) from api.base.utils import absolute_reverse, get_user_auth, get_object_or_error from api.base import permissions as base_permissions @@ -39,15 +41,14 @@ PreprintStorageProviderSerializer, PreprintNodeRelationshipSerializer, PreprintContributorsCreateSerializer, + PreprintsInstitutionsRelationshipSerializer, ) from api.files.serializers import OsfStorageFileSerializer -from api.nodes.serializers import ( - NodeCitationStyleSerializer, -) - from api.identifiers.views import IdentifierList from api.identifiers.serializers import PreprintIdentifierSerializer +from api.institutions.serializers import InstitutionSerializer from api.nodes.views import NodeMixin, NodeContributorsList, NodeContributorDetail, NodeFilesList, NodeStorageProvidersList, NodeStorageProvider +from api.nodes.serializers import NodeCitationStyleSerializer from api.preprints.permissions import ( PreprintPublishedOrAdmin, PreprintPublishedOrWrite, @@ -57,16 +58,14 @@ PreprintFilesPermissions, PreprintInstitutionPermissionList, ) -from api.nodes.permissions import ( - ContributorOrPublic, -) +from api.nodes.permissions import ContributorOrPublic +from api.base.permissions import WriteOrPublicForRelationshipInstitutions from api.requests.permissions import PreprintRequestPermission from api.requests.serializers import PreprintRequestSerializer, PreprintRequestCreateSerializer from api.requests.views import PreprintRequestMixin from api.subjects.views import BaseResourceSubjectsList from api.base.metrics import PreprintMetricsViewMixin from osf.metrics import PreprintDownload, PreprintView -from api.institutions.serializers import InstitutionSerializer class PreprintMixin(NodeMixin): @@ -647,3 +646,34 @@ def get_resource(self): def get_queryset(self): return self.get_resource().affiliated_institutions.all() + + +class PreprintInstitutionsRelationship(JSONAPIBaseView, generics.RetrieveUpdateAPIView, 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 patch(self, *args, **kwargs): + raise MethodNotAllowed(self.request.method) diff --git a/api/registrations/serializers.py b/api/registrations/serializers.py index 6a8f185a1dd..3f13728cc19 100644 --- a/api/registrations/serializers.py +++ b/api/registrations/serializers.py @@ -19,7 +19,6 @@ NodeStorageProviderSerializer, NodeLicenseRelationshipField, NodeLinksSerializer, - update_institutions, NodeLicenseSerializer, NodeContributorsSerializer, RegistrationProviderRelationshipField, @@ -31,13 +30,13 @@ ShowIfVersion, VersionedDateTimeField, ValuesListField, HideIfWithdrawalOrWikiDisabled, ) +from api.institutions.utils import update_institutions from framework.auth.core import Auth from osf.exceptions import NodeStateError from osf.models import Node from osf.utils.registrations import strip_registered_meta_comments from osf.utils.workflows import ApprovalStates - class RegistrationSerializer(NodeSerializer): admin_only_editable_fields = [ 'custom_citation', 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..d8ef0d7e1b3 --- /dev/null +++ b/api_tests/preprints/views/test_preprint_institutions_relationship.py @@ -0,0 +1,308 @@ +import pytest + +from api.base.settings.defaults import API_BASE +from osf_tests.factories import ( + PreprintFactory, + AuthUserFactory, + InstitutionFactory, +) + + +@pytest.mark.django_db +class TestPreprintInstitutionsRelationship: + """Test suite for managing preprint institution relationships.""" + + @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) + user.save() + return user + + @pytest.fixture() + def admin_without_institutional_affilation(self, institution, preprint): + user = AuthUserFactory() + preprint.add_permission(user, 'admin') + return user + + @pytest.fixture() + def institutions(self): + return [InstitutionFactory() for _ in range(3)] + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + @pytest.fixture() + def preprint(self): + return PreprintFactory() + + @pytest.fixture() + def url(self, preprint): + """Fixture that returns the URL for the preprint-institutions relationship endpoint.""" + 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): + """ + Test adding affiliated institutions to a preprint. + + Verifies: + - Unauthorized users cannot add institutions. + - Admins without affiliation cannot add institutions. + - Admins with affiliation can add institutions. + """ + 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): + """ + Test removing affiliated institutions from a preprint. + + Verifies: + - Unauthorized users cannot remove institutions. + - Non-admin users cannot remove institutions. + - Admins without affiliation can remove institutions. + - Admins with affiliation can remove institutions. + """ + 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): + """ + Test retrieving the list of affiliated institutions for a preprint. + + Verifies: + - Unauthenticated users cannot retrieve the list. + - Users without permissions cannot retrieve the list. + - Admins without affiliation can retrieve the list. + - Admins with affiliation can retrieve the list. + """ + 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' + + def test_post_affiliated_institutions(self, app, user, admin_with_institutional_affilation, preprint, url, + institutions, institution): + """ + Test that POST method is not allowed for affiliated institutions. + + Verifies: + - POST requests return a 405 Method Not Allowed status. + """ + add_institutions_payload = { + 'data': [{'type': 'institutions', 'id': institution._id} for institution in institutions] + } + + res = app.post_json_api( + url, + add_institutions_payload, + auth=admin_with_institutional_affilation.auth, + expect_errors=True + ) + assert res.status_code == 405 + + def test_patch_affiliated_institutions(self, app, user, admin_with_institutional_affilation, preprint, url, + institutions, institution): + """ + Test that PATCH method is not allowed for affiliated institutions. + + Verifies: + - PATCH requests return a 405 Method Not Allowed status. + """ + add_institutions_payload = { + 'data': [{'type': 'institutions', 'id': institution._id} for institution in institutions] + } + + res = app.patch_json_api( + url, + add_institutions_payload, + auth=admin_with_institutional_affilation.auth, + expect_errors=True + ) + assert res.status_code == 405 + + def test_delete_affiliated_institution(self, app, user, admin_with_institutional_affilation, admin_without_institutional_affilation, preprint, url, + institution): + """ + Test that DELETE method is not allowed for affiliated institutions. + + Verifies: + - DELETE requests return a 405 Method Not Allowed status. + """ + preprint.affiliated_institutions.add(institution) + preprint.save() + + res = app.delete_json_api( + url, + {'data': [{'type': 'institutions', 'id': institution._id}]}, + auth=admin_with_institutional_affilation.auth, + expect_errors=True + ) + assert res.status_code == 405 + + def test_add_multiple_institutions_affiliations(self, app, admin_with_institutional_affilation, admin_without_institutional_affilation, preprint, url, + institutions): + """ + Test adding multiple institution affiliations to a preprint. + + Verifies: + - Admins with multiple affiliations can add them to a preprint. + """ + admin_with_institutional_affilation.add_or_update_affiliated_institution(institutions[0]) + admin_with_institutional_affilation.add_or_update_affiliated_institution(institutions[1]) + admin_with_institutional_affilation.add_or_update_affiliated_institution(institutions[2]) + admin_with_institutional_affilation.save() + add_institutions_payload = { + 'data': [{'type': 'institutions', 'id': institution._id} for institution in institutions] + } + + assert preprint.affiliated_institutions.all().count() == 0 + res = app.put_json_api( + url, + add_institutions_payload, + auth=admin_with_institutional_affilation.auth, + ) + assert res.status_code == 200 + assert preprint.affiliated_institutions.all().count() == 3 + + preprint.reload() + + def test_remove_only_institutions_affiliations_that_user_has(self, app, user, admin_with_institutional_affilation, preprint, url, + institutions, institution): + """ + Test removing only institutions that the user is affiliated with from a preprint. + + Verifies: + - Admins with multiple affiliations only remove their own affiliations, leaving others unchanged. + """ + preprint.affiliated_institutions.add(*institutions) + assert preprint.affiliated_institutions.all().count() == 3 + + admin_with_institutional_affilation.add_or_update_affiliated_institution(institutions[0]) + admin_with_institutional_affilation.add_or_update_affiliated_institution(institutions[1]) + + update_institution_payload = { + 'data': [{'type': 'institutions', 'id': institution._id}] + } + + res = app.put_json_api( + url, + update_institution_payload, + auth=admin_with_institutional_affilation.auth + ) + assert res.status_code == 200 + assert preprint.affiliated_institutions.all().count() == 2 + assert institution in preprint.affiliated_institutions.all() + assert institutions[2] in preprint.affiliated_institutions.all()