From 9bcd800355f38e78454ba15b1bc3a21da767f0f4 Mon Sep 17 00:00:00 2001 From: Longze Chen Date: Fri, 6 Sep 2024 16:56:04 -0400 Subject: [PATCH] [ENG-5028] Squash all commits for project feature branch (#10743) [ENG-5829] Add Institutional Affiliation mix-in to Preprints #10649 [ENG-5844] Add preprint institution affiliation endpoint #10655 [ENG-5845] Preprint Institutions Relationship #10659 [ENG-5846][ENG-5847] Add affiliated_institutions to preprint list and detail endpoint #10684 [Bug-fix] Fix Self Link for Preprint Affiliated Institutions #10698 [ENG-5908] Add Test Confirming Preprint Affiliated Institution Data is Sent to SHARE #10700 [ENG-5966] 2.0.1 BE: Update permission to support edit as a WRITE contributor #10741 --- api/base/permissions.py | 18 +- api/draft_registrations/serializers.py | 2 +- api/institutions/serializers.py | 10 +- api/institutions/utils.py | 58 +++ api/nodes/permissions.py | 12 - api/nodes/serializers.py | 51 +-- api/nodes/views.py | 2 +- api/preprints/permissions.py | 27 ++ api/preprints/serializers.py | 180 ++++++--- api/preprints/urls.py | 2 + api/preprints/views.py | 85 +++- api/registrations/serializers.py | 3 +- .../preprints/views/test_preprint_detail.py | 373 +----------------- .../test_preprint_detail_author_assertions.py | 300 ++++++++++++++ .../views/test_preprint_institutions.py | 162 ++++++++ ...test_preprint_institutions_relationship.py | 304 ++++++++++++++ .../preprints/views/test_preprint_list.py | 15 + .../0023_preprint_affiliated_institutions.py | 18 + osf/models/mixins.py | 2 +- osf/models/preprint.py | 7 +- osf/models/preprintlog.py | 2 + osf_tests/metadata/test_osf_gathering.py | 10 + osf_tests/test_institutional_affiliation.py | 55 +++ 23 files changed, 1207 insertions(+), 491 deletions(-) create mode 100644 api/institutions/utils.py create mode 100644 api_tests/preprints/views/test_preprint_detail_author_assertions.py create mode 100644 api_tests/preprints/views/test_preprint_institutions.py create mode 100644 api_tests/preprints/views/test_preprint_institutions_relationship.py create mode 100644 osf/migrations/0023_preprint_affiliated_institutions.py create mode 100644 osf_tests/test_institutional_affiliation.py diff --git a/api/base/permissions.py b/api/base/permissions.py index ae2e45e0fcb..50f467a8512 100644 --- a/api/base/permissions.py +++ b/api/base/permissions.py @@ -8,8 +8,10 @@ from framework.auth import oauth_scopes from framework.auth.cas import CasResponse -from osf.models import ApiOAuth2Application, ApiOAuth2PersonalToken +from osf.models import ApiOAuth2Application, ApiOAuth2PersonalToken, Preprint +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 @@ -160,3 +162,17 @@ def has_object_permission(self, request, view, obj): obj = self.get_object(request, view, obj) return super().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: + if isinstance(resource, Preprint): + return resource.can_edit(auth=auth) + return resource.has_permission(auth.user, osf_permissions.WRITE) diff --git a/api/draft_registrations/serializers.py b/api/draft_registrations/serializers.py index 3f2db261c84..84d0c48423c 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 d19e4f7ff0c..f1124d896f8 100644 --- a/api/institutions/serializers.py +++ b/api/institutions/serializers.py @@ -129,7 +129,7 @@ def create(self, validated_data): if not node.has_permission(user, osf_permissions.WRITE): raise exceptions.PermissionDenied(detail='Write permission on node {} required'.format(node_dict['_id'])) if not node.is_affiliated_with_institution(inst): - node.add_affiliated_institution(inst, user, save=True) + node.add_affiliated_institution(inst, user) changes_flag = True if not changes_flag: @@ -174,7 +174,7 @@ def create(self, validated_data): if not registration.has_permission(user, osf_permissions.WRITE): raise exceptions.PermissionDenied(detail='Write permission on registration {} required'.format(registration_dict['_id'])) if not registration.is_affiliated_with_institution(inst): - registration.add_affiliated_institution(inst, user, save=True) + registration.add_affiliated_institution(inst, user) changes_flag = True if not changes_flag: @@ -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..3defb74b031 --- /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 3527d46504d..51145b8fd49 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -294,18 +294,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 317fcb724e8..f70486be776 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=f'Institution with id "{inst_id}" was not found') - 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=f'User needs to be affiliated with {inst.name}', - ) - node.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}', - ) - node.add_affiliated_institution(inst, user) - - class RegionRelationshipField(RelationshipField): def to_internal_value(self, data): @@ -1479,13 +1441,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 c19b7a2762a..93879e2d40a 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/permissions.py b/api/preprints/permissions.py index 0d64f15662d..7bfaacdde3d 100644 --- a/api/preprints/permissions.py +++ b/api/preprints/permissions.py @@ -137,3 +137,30 @@ def has_object_permission(self, request, view, obj): raise exceptions.PermissionDenied(detail='Withdrawn preprints may not be edited') return True raise exceptions.NotFound + + +class PreprintInstitutionPermissionList(permissions.BasePermission): + """ + Custom permission class for checking access to a list of institutions + associated with a preprint. + + Permissions: + - Allows safe methods (GET, HEAD, OPTIONS) for public preprints. + - For private preprints, checks if the user has read permissions. + + Methods: + - has_object_permission: Raises MethodNotAllowed for non-safe methods and + checks if the user has the necessary permissions to access private preprints. + """ + def has_object_permission(self, request, view, obj): + if request.method not in permissions.SAFE_METHODS: + raise exceptions.MethodNotAllowed(method=request.method) + + if obj.is_public: + return True + + auth = get_user_auth(request) + if not auth.user: + return False + else: + return obj.has_permission(auth.user, osf_permissions.READ) diff --git a/api/preprints/serializers.py b/api/preprints/serializers.py index 2646b5ea413..ca3a516477c 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): @@ -206,6 +206,16 @@ class PreprintSerializer(TaxonomizableSerializerMixin, MetricsSerializerMixin, J ), ) + affiliated_institutions = RelationshipField( + related_view='preprints:preprints-institutions', + related_view_kwargs={'preprint_id': '<_id>'}, + self_view='preprints:preprint-relationships-institutions', + self_view_kwargs={'preprint_id': '<_id>'}, + read_only=False, + required=False, + allow_null=True, + ) + links = LinksField( { 'self': 'get_preprint_url', @@ -359,59 +369,7 @@ def update(self, preprint, validated_data): preprint.custom_publication_citation = validated_data['custom_publication_citation'] or None save_preprint = True - if 'has_coi' in validated_data: - try: - preprint.update_has_coi(auth, validated_data['has_coi']) - except PreprintStateError as e: - raise exceptions.ValidationError(detail=str(e)) - - if 'conflict_of_interest_statement' in validated_data: - try: - preprint.update_conflict_of_interest_statement(auth, validated_data['conflict_of_interest_statement']) - except PreprintStateError as e: - raise exceptions.ValidationError(detail=str(e)) - - if 'has_data_links' in validated_data: - try: - preprint.update_has_data_links(auth, validated_data['has_data_links']) - except PreprintStateError as e: - raise exceptions.ValidationError(detail=str(e)) - - if 'why_no_data' in validated_data: - try: - preprint.update_why_no_data(auth, validated_data['why_no_data']) - except PreprintStateError as e: - raise exceptions.ValidationError(detail=str(e)) - - if 'data_links' in validated_data: - try: - preprint.update_data_links(auth, validated_data['data_links']) - except PreprintStateError as e: - raise exceptions.ValidationError(detail=str(e)) - - if 'has_prereg_links' in validated_data: - try: - preprint.update_has_prereg_links(auth, validated_data['has_prereg_links']) - except PreprintStateError as e: - raise exceptions.ValidationError(detail=str(e)) - - if 'why_no_prereg' in validated_data: - try: - preprint.update_why_no_prereg(auth, validated_data['why_no_prereg']) - except PreprintStateError as e: - raise exceptions.ValidationError(detail=str(e)) - - if 'prereg_links' in validated_data: - try: - preprint.update_prereg_links(auth, validated_data['prereg_links']) - except PreprintStateError as e: - raise exceptions.ValidationError(detail=str(e)) - - if 'prereg_link_info' in validated_data: - try: - preprint.update_prereg_link_info(auth, validated_data['prereg_link_info']) - except PreprintStateError as e: - raise exceptions.ValidationError(detail=str(e)) + self.handle_author_assertions(preprint, validated_data, auth) if published is not None: if not preprint.primary_file: @@ -438,6 +396,76 @@ def update(self, preprint, validated_data): return preprint + def handle_author_assertions(self, preprint, validated_data, auth): + author_assertions = { + 'has_coi', + 'conflict_of_interest_statement', + 'has_data_links', + 'why_no_data', + 'data_links', + 'why_no_prereg', + 'prereg_links', + 'has_prereg_links', + 'prereg_link_info', + } + if author_assertions & validated_data.keys(): + if not preprint.is_admin_contributor(auth.user): + raise exceptions.PermissionDenied('User must be admin to add author assertions') + + if 'has_coi' in validated_data: + try: + preprint.update_has_coi(auth, validated_data['has_coi']) + except PreprintStateError as e: + raise exceptions.ValidationError(detail=str(e)) + + if 'conflict_of_interest_statement' in validated_data: + try: + preprint.update_conflict_of_interest_statement(auth, validated_data['conflict_of_interest_statement']) + except PreprintStateError as e: + raise exceptions.ValidationError(detail=str(e)) + + if 'has_data_links' in validated_data: + try: + preprint.update_has_data_links(auth, validated_data['has_data_links']) + except PreprintStateError as e: + raise exceptions.ValidationError(detail=str(e)) + + if 'why_no_data' in validated_data: + try: + preprint.update_why_no_data(auth, validated_data['why_no_data']) + except PreprintStateError as e: + raise exceptions.ValidationError(detail=str(e)) + + if 'data_links' in validated_data: + try: + preprint.update_data_links(auth, validated_data['data_links']) + except PreprintStateError as e: + raise exceptions.ValidationError(detail=str(e)) + + if 'has_prereg_links' in validated_data: + try: + preprint.update_has_prereg_links(auth, validated_data['has_prereg_links']) + except PreprintStateError as e: + raise exceptions.ValidationError(detail=str(e)) + + if 'why_no_prereg' in validated_data: + try: + preprint.update_why_no_prereg(auth, validated_data['why_no_prereg']) + except PreprintStateError as e: + raise exceptions.ValidationError(detail=str(e)) + + if 'prereg_links' in validated_data: + try: + preprint.update_prereg_links(auth, validated_data['prereg_links']) + except PreprintStateError as e: + raise exceptions.ValidationError(detail=str(e)) + + if 'prereg_link_info' in validated_data: + try: + preprint.update_prereg_link_info(auth, validated_data['prereg_link_info']) + except PreprintStateError as e: + raise exceptions.ValidationError(detail=str(e)) + def set_field(self, func, val, auth, save=False): try: func(val, auth) @@ -589,3 +617,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 f3de2782265..5971d61c7e5 100644 --- a/api/preprints/urls.py +++ b/api/preprints/urls.py @@ -20,4 +20,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.PreprintInstitutionsRelationship.as_view(), name=views.PreprintInstitutionsRelationship.view_name), ] diff --git a/api/preprints/views.py b/api/preprints/views.py index 2f6bbad0480..4c3c52f936f 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,13 @@ 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, + JSONAPIRelationshipParser, + JSONAPIRelationshipParserForRegularJSON, ) - 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 @@ -35,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, @@ -51,10 +56,10 @@ AdminOrPublic, ContributorDetailPermissions, 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 @@ -62,6 +67,7 @@ from api.base.metrics import PreprintMetricsViewMixin from osf.metrics import PreprintDownload, PreprintView + class PreprintMixin(NodeMixin): serializer_class = PreprintSerializer preprint_lookup_url_kwarg = 'preprint_id' @@ -647,3 +653,60 @@ 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, + PreprintInstitutionPermissionList, + ) + + required_read_scopes = [CoreScopes.PREPRINTS_READ, CoreScopes.INSTITUTION_READ] + required_write_scopes = [CoreScopes.NULL] + serializer_class = InstitutionSerializer + + model = Institution + view_category = 'preprints' + view_name = 'preprints-institutions' + + ordering = ('-id',) + + def get_resource(self): + return self.get_preprint() + + 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 6e575bd33ad..f1676fc1ff7 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_detail.py b/api_tests/preprints/views/test_preprint_detail.py index 7112a1a21e6..7e3b279c406 100644 --- a/api_tests/preprints/views/test_preprint_detail.py +++ b/api_tests/preprints/views/test_preprint_detail.py @@ -18,7 +18,6 @@ from osf.models import ( NodeLicense, PreprintContributor, - PreprintLog ) from osf.utils.permissions import WRITE from osf.utils.workflows import DefaultStates @@ -28,6 +27,7 @@ ProjectFactory, SubjectFactory, PreprintProviderFactory, + InstitutionFactory ) from website.settings import DOI_FORMAT, CROSSREF_URL @@ -58,6 +58,10 @@ class TestPreprintDetail: def preprint(self, user): return PreprintFactory(creator=user) + @pytest.fixture() + def institution(self): + return InstitutionFactory() + @pytest.fixture() def preprint_pre_mod(self, user): return PreprintFactory(reviews_workflow='pre-moderation', is_published=False, creator=user) @@ -215,6 +219,18 @@ def test_preprint_embed_identifiers(self, app, user, preprint, url): link = res.json['data']['relationships']['identifiers']['links']['related']['href'] assert f'{url}identifiers/' in link + def test_return_affiliated_institutions(self, app, user, preprint, institution, url): + """ + Confirmation test for the the new preprint affiliated institutions feature + """ + preprint.affiliated_institutions.add(institution) + res = app.get(url) + assert res.status_code == 200 + relationship_link = res.json['data']['relationships']['affiliated_institutions']['links']['related']['href'] + assert f'/v2/preprints/{preprint._id}/institutions/' in relationship_link + relationship_link = res.json['data']['relationships']['affiliated_institutions']['links']['self']['href'] + assert f'/v2/preprints/{preprint._id}/relationships/institutions/' in relationship_link + @pytest.mark.django_db class TestPreprintDelete: @@ -819,361 +835,6 @@ def test_update_preprint_task_called_on_api_update( assert mock_on_preprint_updated.called - def test_update_has_coi(self, app, user, preprint, url): - update_payload = build_preprint_update_payload( - preprint._id, - attributes={'has_coi': True} - ) - - contrib = AuthUserFactory() - preprint.add_contributor(contrib, READ) - res = app.patch_json_api(url, update_payload, auth=contrib.auth, expect_errors=True) - assert res.status_code == 403 - assert res.json['errors'][0]['detail'] == 'You do not have permission to perform this action.' - - res = app.patch_json_api(url, update_payload, auth=user.auth) - - assert res.status_code == 200 - assert res.json['data']['attributes']['has_coi'] - - preprint.reload() - assert preprint.has_coi - log = preprint.logs.first() - assert log.action == PreprintLog.UPDATE_HAS_COI - assert log.params == {'preprint': preprint._id, 'user': user._id, 'value': True} - - def test_update_conflict_of_interest_statement(self, app, user, preprint, url): - update_payload = build_preprint_update_payload( - preprint._id, - attributes={'conflict_of_interest_statement': 'Owns shares in Closed Science Corporation.'} - ) - contrib = AuthUserFactory() - preprint.add_contributor(contrib, READ) - res = app.patch_json_api(url, update_payload, auth=contrib.auth, expect_errors=True) - assert res.status_code == 403 - assert res.json['errors'][0]['detail'] == 'You do not have permission to perform this action.' - - preprint.has_coi = False - preprint.save() - res = app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - - assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == 'You do not have the ability to edit a conflict of interest while the ' \ - 'has_coi field is set to false or unanswered' - - preprint.has_coi = True - preprint.save() - res = app.patch_json_api(url, update_payload, auth=user.auth) - - assert res.status_code == 200 - assert res.json['data']['attributes']['conflict_of_interest_statement'] ==\ - 'Owns shares in Closed Science Corporation.' - - preprint.reload() - assert preprint.conflict_of_interest_statement == 'Owns shares in Closed Science Corporation.' - log = preprint.logs.first() - assert log.action == PreprintLog.UPDATE_COI_STATEMENT - assert log.params == {'preprint': preprint._id, 'user': user._id} - - def test_update_has_data_links(self, app, user, preprint, url): - update_payload = build_preprint_update_payload(preprint._id, attributes={'has_data_links': 'available'}) - - contrib = AuthUserFactory() - preprint.add_contributor(contrib, READ) - res = app.patch_json_api(url, update_payload, auth=contrib.auth, expect_errors=True) - assert res.status_code == 403 - assert res.json['errors'][0]['detail'] == 'You do not have permission to perform this action.' - - res = app.patch_json_api(url, update_payload, auth=user.auth) - - assert res.status_code == 200 - assert res.json['data']['attributes']['has_data_links'] == 'available' - - preprint.reload() - assert preprint.has_data_links - log = preprint.logs.first() - assert log.action == PreprintLog.UPDATE_HAS_DATA_LINKS - assert log.params == {'value': 'available', 'user': user._id, 'preprint': preprint._id} - - def test_update_why_no_data(self, app, user, preprint, url): - update_payload = build_preprint_update_payload(preprint._id, attributes={'why_no_data': 'My dog ate it.'}) - - contrib = AuthUserFactory() - preprint.add_contributor(contrib, READ) - res = app.patch_json_api(url, update_payload, auth=contrib.auth, expect_errors=True) - assert res.status_code == 403 - assert res.json['errors'][0]['detail'] == 'You do not have permission to perform this action.' - - res = app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - - assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == 'You cannot edit this statement while your data links availability' \ - ' is set to true or is unanswered.' - - preprint.has_data_links = 'no' - preprint.save() - res = app.patch_json_api(url, update_payload, auth=user.auth) - - assert res.status_code == 200 - assert res.json['data']['attributes']['why_no_data'] == 'My dog ate it.' - - preprint.reload() - assert preprint.why_no_data - log = preprint.logs.first() - assert log.action == PreprintLog.UPDATE_WHY_NO_DATA - assert log.params == {'user': user._id, 'preprint': preprint._id} - - def test_update_data_links(self, app, user, preprint, url): - data_links = ['http://www.JasonKelce.com', 'http://www.ItsTheWholeTeam.com/'] - update_payload = build_preprint_update_payload(preprint._id, attributes={'data_links': data_links}) - - contrib = AuthUserFactory() - preprint.add_contributor(contrib, READ) - res = app.patch_json_api(url, update_payload, auth=contrib.auth, expect_errors=True) - assert res.status_code == 403 - assert res.json['errors'][0]['detail'] == 'You do not have permission to perform this action.' - - preprint.has_data_links = 'no' - preprint.save() - res = app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - - assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == 'You cannot edit this statement while your data links availability' \ - ' is set to false or is unanswered.' - - preprint.has_data_links = 'available' - preprint.save() - res = app.patch_json_api(url, update_payload, auth=user.auth) - - assert res.status_code == 200 - assert res.json['data']['attributes']['data_links'] == data_links - - preprint.reload() - assert preprint.data_links == data_links - log = preprint.logs.first() - assert log.action == PreprintLog.UPDATE_DATA_LINKS - assert log.params == {'user': user._id, 'preprint': preprint._id} - - update_payload = build_preprint_update_payload(preprint._id, attributes={'data_links': 'maformed payload'}) - res = app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - - assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == 'Expected a list of items but got type "str".' - - def test_invalid_data_links(self, app, user, preprint, url): - preprint.has_data_links = 'available' - preprint.save() - - update_payload = build_preprint_update_payload(preprint._id, attributes={'data_links': ['thisaintright']}) - - res = app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == 'Enter a valid URL.' - - def test_update_has_prereg_links(self, app, user, preprint, url): - update_payload = build_preprint_update_payload(preprint._id, attributes={'has_prereg_links': 'available'}) - - contrib = AuthUserFactory() - preprint.add_contributor(contrib, READ) - res = app.patch_json_api(url, update_payload, auth=contrib.auth, expect_errors=True) - assert res.status_code == 403 - assert res.json['errors'][0]['detail'] == 'You do not have permission to perform this action.' - - res = app.patch_json_api(url, update_payload, auth=user.auth) - - assert res.status_code == 200 - assert res.json['data']['attributes']['has_prereg_links'] == 'available' - - preprint.reload() - assert preprint.has_prereg_links - log = preprint.logs.first() - assert log.action == PreprintLog.UPDATE_HAS_PREREG_LINKS - assert log.params == {'value': 'available', 'user': user._id, 'preprint': preprint._id} - - def test_invalid_prereg_links(self, app, user, preprint, url): - preprint.has_prereg_links = 'available' - preprint.save() - - update_payload = build_preprint_update_payload(preprint._id, attributes={'prereg_links': ['thisaintright']}) - - res = app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - - assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == 'Enter a valid URL.' - - def test_no_data_links_clears_links(self, app, user, preprint, url): - preprint.has_data_links = 'available' - preprint.data_links = ['http://www.apple.com'] - preprint.save() - - update_payload = build_preprint_update_payload(preprint._id, attributes={'has_data_links': 'no'}) - - res = app.patch_json_api(url, update_payload, auth=user.auth) - - assert res.status_code == 200 - assert res.json['data']['attributes']['has_data_links'] == 'no' - assert res.json['data']['attributes']['data_links'] == [] - - def test_no_prereg_links_clears_links(self, app, user, preprint, url): - preprint.has_prereg_links = 'available' - preprint.prereg_links = ['http://example.com'] - preprint.prereg_link_info = 'prereg_analysis' - preprint.save() - - update_payload = build_preprint_update_payload(preprint._id, attributes={'has_prereg_links': 'no'}) - - res = app.patch_json_api(url, update_payload, auth=user.auth) - - assert res.status_code == 200 - assert res.json['data']['attributes']['has_prereg_links'] == 'no' - assert res.json['data']['attributes']['prereg_links'] == [] - assert not res.json['data']['attributes']['prereg_link_info'] - - def test_update_why_no_prereg(self, app, user, preprint, url): - update_payload = build_preprint_update_payload(preprint._id, attributes={'why_no_prereg': 'My dog ate it.'}) - - contrib = AuthUserFactory() - preprint.add_contributor(contrib, READ) - res = app.patch_json_api(url, update_payload, auth=contrib.auth, expect_errors=True) - assert res.status_code == 403 - assert res.json['errors'][0]['detail'] == 'You do not have permission to perform this action.' - - res = app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - - assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == 'You cannot edit this statement while your prereg links availability' \ - ' is set to true or is unanswered.' - - preprint.has_prereg_links = False - preprint.save() - res = app.patch_json_api(url, update_payload, auth=user.auth) - - assert res.status_code == 200 - assert res.json['data']['attributes']['why_no_prereg'] == 'My dog ate it.' - - preprint.reload() - assert preprint.why_no_prereg - log = preprint.logs.first() - assert log.action == PreprintLog.UPDATE_WHY_NO_PREREG - assert log.params == {'user': user._id, 'preprint': preprint._id} - - def test_update_prereg_links(self, app, user, preprint, url): - - prereg_links = ['http://www.JasonKelce.com', 'http://www.ItsTheWholeTeam.com/'] - update_payload = build_preprint_update_payload(preprint._id, attributes={'prereg_links': prereg_links}) - - contrib = AuthUserFactory() - preprint.add_contributor(contrib, READ) - res = app.patch_json_api(url, update_payload, auth=contrib.auth, expect_errors=True) - assert res.status_code == 403 - assert res.json['errors'][0]['detail'] == 'You do not have permission to perform this action.' - - preprint.has_prereg_links = 'no' - preprint.save() - res = app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - - assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == 'You cannot edit this field while your prereg links availability' \ - ' is set to false or is unanswered.' - - preprint.has_prereg_links = 'available' - preprint.save() - res = app.patch_json_api(url, update_payload, auth=user.auth) - - assert res.status_code == 200 - assert res.json['data']['attributes']['prereg_links'] == prereg_links - - preprint.reload() - assert preprint.prereg_links == prereg_links - log = preprint.logs.first() - assert log.action == PreprintLog.UPDATE_PREREG_LINKS - assert log.params == {'user': user._id, 'preprint': preprint._id} - - update_payload = build_preprint_update_payload(preprint._id, attributes={'prereg_links': 'maformed payload'}) - res = app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - - assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == 'Expected a list of items but got type "str".' - - def test_update_prereg_link_info(self, app, user, preprint, url): - update_payload = build_preprint_update_payload( - preprint._id, - attributes={'prereg_link_info': 'prereg_designs'} - ) - - preprint.has_prereg_links = 'no' - preprint.save() - res = app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - - assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == 'You cannot edit this field while your prereg links availability' \ - ' is set to false or is unanswered.' - - preprint.has_prereg_links = 'available' - preprint.save() - res = app.patch_json_api(url, update_payload, auth=user.auth) - - assert res.status_code == 200 - assert res.json['data']['attributes']['prereg_link_info'] == 'prereg_designs' - - preprint.reload() - assert preprint.prereg_link_info == 'prereg_designs' - log = preprint.logs.first() - assert log.action == PreprintLog.UPDATE_PREREG_LINKS_INFO - assert log.params == {'user': user._id, 'preprint': preprint._id} - - update_payload = build_preprint_update_payload( - preprint._id, - attributes={'prereg_link_info': 'maformed payload'} - ) - res = app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - - assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == '"maformed payload" is not a valid choice.' - - def test_sloan_updates(self, app, user, preprint, url): - """ - - Tests to ensure updating a preprint with unchanged data does not create superfluous log statements. - - Tests to ensure various dependent fields can be updated in a single request. - """ - preprint.has_prereg_links = 'available' - preprint.prereg_links = ['http://no-sf.io'] - preprint.prereg_link_info = 'prereg_designs' - preprint.save() - - update_payload = build_preprint_update_payload( - preprint._id, - attributes={ - 'has_prereg_links': 'available', - 'prereg_link_info': 'prereg_designs', - 'prereg_links': ['http://osf.io'], # changing here should be only non-factory created log. - } - ) - app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - - # Any superfluous log statements? - logs = preprint.logs.all().values_list('action', 'params') - assert logs.count() == 3 # actions should be: 'subjects_updated', 'published', 'prereg_links_updated' - assert logs.latest() == ('prereg_links_updated', {'user': user._id, 'preprint': preprint._id}) - - # Can we set `has_prereg_links` to false and update `why_no_prereg` in a single request? - update_payload = build_preprint_update_payload( - preprint._id, - attributes={ - 'has_prereg_links': 'no', - 'why_no_prereg': 'My dog ate it.' - } - ) - res = app.patch_json_api(url, update_payload, auth=user.auth, expect_errors=True) - - assert res.status_code == 200 - assert res.json['data']['attributes']['has_prereg_links'] == 'no' - assert res.json['data']['attributes']['why_no_prereg'] == 'My dog ate it.' - - preprint.refresh_from_db() - assert preprint.has_prereg_links == 'no' - assert preprint.why_no_prereg == 'My dog ate it.' - @pytest.mark.django_db class TestPreprintUpdateSubjects(UpdateSubjectsMixin): diff --git a/api_tests/preprints/views/test_preprint_detail_author_assertions.py b/api_tests/preprints/views/test_preprint_detail_author_assertions.py new file mode 100644 index 00000000000..63dc8696d41 --- /dev/null +++ b/api_tests/preprints/views/test_preprint_detail_author_assertions.py @@ -0,0 +1,300 @@ +import pytest + +from osf.utils.permissions import READ, WRITE, ADMIN +from api.base.settings.defaults import API_BASE +from osf.models import PreprintLog +from osf_tests.factories import PreprintFactory, AuthUserFactory + + +def build_preprint_update_payload( + node_id, attributes=None, relationships=None, + jsonapi_type='preprints'): + payload = { + 'data': { + 'id': node_id, + 'type': jsonapi_type, + 'attributes': attributes, + 'relationships': relationships + } + } + return payload + + +@pytest.mark.django_db +@pytest.mark.enable_enqueue_task +class TestPreprintUpdateWithAuthorAssertion: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def preprint(self, user): + """ + Creator is not admin permission + """ + preprint = PreprintFactory(creator=user) + admin = AuthUserFactory() + preprint.add_contributor(admin, ADMIN) + preprint.add_contributor(user, READ) + return preprint + + @pytest.fixture() + def url(self, preprint): + return f'/{API_BASE}preprints/{preprint._id}/' + + @pytest.fixture() + def read_contrib(self, preprint): + contrib = AuthUserFactory() + preprint.add_contributor(contrib, READ) + return contrib + + @pytest.fixture() + def write_contrib(self, preprint): + contrib = AuthUserFactory() + preprint.add_contributor(contrib, WRITE) + return contrib + + @pytest.fixture() + def admin_contrib(self, preprint): + contrib = AuthUserFactory() + preprint.add_contributor(contrib, ADMIN) + return contrib + + def assert_permission(self, app, url, contrib, attributes, expected_status): + update_payload = build_preprint_update_payload(node_id=contrib._id, attributes=attributes) + res = app.patch_json_api(url, update_payload, auth=contrib.auth, expect_errors=True) + assert res.status_code == expected_status + + # Testing permissions for updating has_coi + def test_update_has_coi_permission_denied(self, app, read_contrib, url): + self.assert_permission(app, url, read_contrib, {'has_coi': True}, 403) + + def test_update_has_coi_permission_granted_write(self, app, write_contrib, url): + self.assert_permission(app, url, write_contrib, {'has_coi': True}, 403) + + def test_update_has_coi_permission_granted_admin(self, app, admin_contrib, url): + self.assert_permission(app, url, admin_contrib, {'has_coi': True}, 200) + + def test_update_has_coi_permission_granted_creator(self, app, user, url): + self.assert_permission(app, url, user, {'has_coi': True}, 403) + + # Testing permissions for updating conflict_of_interest_statement + def test_update_conflict_of_interest_statement_permission_denied(self, app, read_contrib, url): + self.assert_permission(app, url, read_contrib, {'conflict_of_interest_statement': 'Test'}, 403) + + def test_update_conflict_of_interest_statement_permission_granted_write(self, app, write_contrib, preprint, url): + preprint.has_coi = True + preprint.save() + self.assert_permission(app, url, write_contrib, {'conflict_of_interest_statement': 'Test'}, 403) + + def test_update_conflict_of_interest_statement_permission_granted_admin(self, app, admin_contrib, preprint, url): + preprint.has_coi = True + preprint.save() + self.assert_permission(app, url, admin_contrib, {'conflict_of_interest_statement': 'Test'}, 200) + + def test_update_conflict_of_interest_statement_permission_granted_creator(self, app, user, preprint, url): + preprint.has_coi = True + preprint.save() + self.assert_permission(app, url, user, {'conflict_of_interest_statement': 'Test'}, 403) + + # Testing permissions for updating has_data_links + def test_update_has_data_links_permission_denied(self, app, read_contrib, url): + self.assert_permission(app, url, read_contrib, {'has_data_links': 'available'}, 403) + + def test_update_has_data_links_permission_granted_write(self, app, write_contrib, url): + self.assert_permission(app, url, write_contrib, {'has_data_links': 'available'}, 403) + + def test_update_has_data_links_permission_granted_admin(self, app, admin_contrib, url): + self.assert_permission(app, url, admin_contrib, {'has_data_links': 'available'}, 200) + + def test_update_has_data_links_permission_granted_creator(self, app, user, url): + self.assert_permission(app, url, user, {'has_data_links': 'available'}, 403) + + # Testing permissions for updating why_no_data + def test_update_why_no_data_permission_denied(self, app, read_contrib, url): + self.assert_permission(app, url, read_contrib, {'why_no_data': 'My dog ate it.'}, 403) + + def test_update_why_no_data_permission_granted_write(self, app, write_contrib, preprint, url): + preprint.has_data_links = 'no' + preprint.save() + self.assert_permission(app, url, write_contrib, {'why_no_data': 'My dog ate it.'}, 403) + + def test_update_why_no_data_permission_granted_admin(self, app, admin_contrib, preprint, url): + preprint.has_data_links = 'no' + preprint.save() + self.assert_permission(app, url, admin_contrib, {'why_no_data': 'My dog ate it.'}, 200) + + def test_update_why_no_data_permission_granted_creator(self, app, user, preprint, url): + preprint.has_data_links = 'no' + preprint.save() + self.assert_permission(app, url, user, {'why_no_data': 'My dog ate it.'}, 403) + + # Testing permissions for updating data_links + def test_update_data_links_permission_denied(self, app, read_contrib, url): + data_links = ['http://www.JasonKelce.com', 'http://www.ItsTheWholeTeam.com/'] + self.assert_permission(app, url, read_contrib, {'data_links': data_links}, 403) + + def test_update_data_links_permission_granted_write(self, app, write_contrib, preprint, url): + data_links = ['http://www.JasonKelce.com', 'http://www.ItsTheWholeTeam.com/'] + preprint.has_data_links = 'available' + preprint.save() + self.assert_permission(app, url, write_contrib, {'data_links': data_links}, 403) + + def test_update_data_links_permission_granted_admin(self, app, admin_contrib, preprint, url): + data_links = ['http://www.JasonKelce.com', 'http://www.ItsTheWholeTeam.com/'] + preprint.has_data_links = 'available' + preprint.save() + self.assert_permission(app, url, admin_contrib, {'data_links': data_links}, 200) + + def test_update_data_links_permission_granted_creator(self, app, user, preprint, url): + data_links = ['http://www.JasonKelce.com', 'http://www.ItsTheWholeTeam.com/'] + preprint.has_data_links = 'available' + preprint.save() + self.assert_permission(app, url, user, {'data_links': data_links}, 403) + + def test_update_data_links_invalid_payload(self, app, admin_contrib, url): + update_payload = build_preprint_update_payload(node_id=admin_contrib._id, attributes={'data_links': 'maformed payload'}) + res = app.patch_json_api(url, update_payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'Expected a list of items but got type "str".' + + def test_update_data_links_invalid_url(self, app, admin_contrib, preprint, url): + preprint.has_data_links = 'available' + preprint.save() + update_payload = build_preprint_update_payload(node_id=admin_contrib._id, attributes={'data_links': ['thisaintright']}) + res = app.patch_json_api(url, update_payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'Enter a valid URL.' + + # Testing permissions for updating has_prereg_links + def test_update_has_prereg_links_permission_denied(self, app, read_contrib, url): + self.assert_permission(app, url, read_contrib, {'has_prereg_links': 'available'}, 403) + + def test_update_has_prereg_links_permission_granted_write(self, app, write_contrib, url): + self.assert_permission(app, url, write_contrib, {'has_prereg_links': 'available'}, 403) + + def test_update_has_prereg_links_permission_granted_admin(self, app, admin_contrib, url): + self.assert_permission(app, url, admin_contrib, {'has_prereg_links': 'available'}, 200) + + def test_update_has_prereg_links_permission_granted_creator(self, app, user, url): + self.assert_permission(app, url, user, {'has_prereg_links': 'available'}, 403) + + # Testing permissions for updating prereg_links + def test_update_prereg_links_permission_denied(self, app, read_contrib, url): + prereg_links = ['http://www.JasonKelce.com', 'http://www.ItsTheWholeTeam.com/'] + self.assert_permission(app, url, read_contrib, {'prereg_links': prereg_links}, 403) + + def test_update_prereg_links_permission_granted_write(self, app, write_contrib, preprint, url): + prereg_links = ['http://www.JasonKelce.com', 'http://www.ItsTheWholeTeam.com/'] + preprint.has_prereg_links = 'available' + preprint.save() + self.assert_permission(app, url, write_contrib, {'prereg_links': prereg_links}, 403) + + def test_update_prereg_links_permission_granted_admin(self, app, admin_contrib, preprint, url): + prereg_links = ['http://www.JasonKelce.com', 'http://www.ItsTheWholeTeam.com/'] + preprint.has_prereg_links = 'available' + preprint.save() + self.assert_permission(app, url, admin_contrib, {'prereg_links': prereg_links}, 200) + + def test_update_prereg_links_permission_granted_creator(self, app, user, preprint, url): + prereg_links = ['http://www.JasonKelce.com', 'http://www.ItsTheWholeTeam.com/'] + preprint.has_prereg_links = 'available' + preprint.save() + self.assert_permission(app, url, user, {'prereg_links': prereg_links}, 403) + + def test_update_prereg_links_invalid_payload(self, app, admin_contrib, url): + update_payload = build_preprint_update_payload(node_id=admin_contrib._id, attributes={'prereg_links': 'maformed payload'}) + res = app.patch_json_api(url, update_payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'Expected a list of items but got type "str".' + + def test_update_prereg_links_invalid_url(self, app, admin_contrib, preprint, url): + preprint.has_prereg_links = 'available' + preprint.save() + update_payload = build_preprint_update_payload(node_id=admin_contrib._id, attributes={'prereg_links': ['thisaintright']}) + res = app.patch_json_api(url, update_payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'Enter a valid URL.' + + def test_update_prereg_link_info_fail_prereg_links(self, app, admin_contrib, preprint, url): + update_payload = build_preprint_update_payload(node_id=admin_contrib._id, attributes={'prereg_link_info': 'prereg_designs'}) + preprint.has_prereg_links = 'no' + preprint.save() + res = app.patch_json_api(url, update_payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'You cannot edit this field while your prereg links availability is set to false or is unanswered.' + + def test_update_prereg_link_info_success(self, app, admin_contrib, preprint, url): + update_payload = build_preprint_update_payload(node_id=admin_contrib._id, attributes={'prereg_link_info': 'prereg_designs'}) + preprint.has_prereg_links = 'available' + preprint.save() + res = app.patch_json_api(url, update_payload, auth=admin_contrib.auth) + assert res.status_code == 200 + assert res.json['data']['attributes']['prereg_link_info'] == 'prereg_designs' + preprint.reload() + assert preprint.prereg_link_info == 'prereg_designs' + log = preprint.logs.first() + assert log.action == PreprintLog.UPDATE_PREREG_LINKS_INFO + assert log.params == {'user': admin_contrib._id, 'preprint': preprint._id} + + def test_update_prereg_link_info_invalid_payload(self, app, admin_contrib, url): + update_payload = build_preprint_update_payload(node_id=admin_contrib._id, attributes={'prereg_link_info': 'maformed payload'}) + res = app.patch_json_api(url, update_payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == '"maformed payload" is not a valid choice.' + + def test_no_prereg_links_clears_links(self, app, admin_contrib, preprint, url): + preprint.has_prereg_links = 'available' + preprint.prereg_links = ['http://example.com'] + preprint.prereg_link_info = 'prereg_analysis' + preprint.save() + update_payload = build_preprint_update_payload(node_id=admin_contrib._id, attributes={'has_prereg_links': 'no'}) + res = app.patch_json_api(url, update_payload, auth=admin_contrib.auth) + assert res.status_code == 200 + assert res.json['data']['attributes']['has_prereg_links'] == 'no' + assert res.json['data']['attributes']['prereg_links'] == [] + assert not res.json['data']['attributes']['prereg_link_info'] + + def test_no_data_links_clears_links(self, app, admin_contrib, preprint, url): + preprint.has_data_links = 'available' + preprint.data_links = ['http://www.apple.com'] + preprint.save() + update_payload = build_preprint_update_payload(node_id=admin_contrib._id, attributes={'has_data_links': 'no'}) + res = app.patch_json_api(url, update_payload, auth=admin_contrib.auth) + assert res.status_code == 200 + assert res.json['data']['attributes']['has_data_links'] == 'no' + assert res.json['data']['attributes']['data_links'] == [] + + def test_sloan_updates(self, app, admin_contrib, preprint, url): + preprint.has_prereg_links = 'available' + preprint.prereg_links = ['http://no-sf.io'] + preprint.prereg_link_info = 'prereg_designs' + preprint.save() + update_payload = build_preprint_update_payload( + node_id=preprint._id, + attributes={ + 'has_prereg_links': 'available', + 'prereg_link_info': 'prereg_designs', + 'prereg_links': ['http://osf.io'], + } + ) + app.patch_json_api(url, update_payload, auth=admin_contrib.auth, expect_errors=True) + logs = preprint.logs.all().values_list('action', 'params') + assert logs.count() == 5 + assert logs.latest() == ('prereg_links_updated', {'user': admin_contrib._id, 'preprint': preprint._id}) + + update_payload = build_preprint_update_payload( + node_id=preprint._id, + attributes={ + 'has_prereg_links': 'no', + 'why_no_prereg': 'My dog ate it.' + } + ) + res = app.patch_json_api(url, update_payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 200 + assert res.json['data']['attributes']['has_prereg_links'] == 'no' + assert res.json['data']['attributes']['why_no_prereg'] == 'My dog ate it.' + preprint.refresh_from_db() + assert preprint.has_prereg_links == 'no' + assert preprint.why_no_prereg == 'My dog ate it.' 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..a874ffa15bf --- /dev/null +++ b/api_tests/preprints/views/test_preprint_institutions.py @@ -0,0 +1,162 @@ +import pytest + +from api.base.settings.defaults import API_BASE +from osf_tests.factories import ( + PreprintFactory, + AuthUserFactory, + InstitutionFactory, +) +from osf.utils import permissions as osf_permissions + + +@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 invalid_url(self): + return f'/{API_BASE}preprints/invalid_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, osf_permissions.READ) + return user + + @pytest.fixture() + def write_contrib(self, private_preprint): + user = AuthUserFactory() + private_preprint.add_permission(user, osf_permissions.WRITE) + return user + + @pytest.fixture() + def admin_contrib(self, private_preprint): + user = AuthUserFactory() + private_preprint.add_permission(user, osf_permissions.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 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 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' + + def test_invalid_preprint_id(self, app, invalid_url): + res = app.get(invalid_url, expect_errors=True) + assert res.status_code == 404 + + +@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 invalid_url(self): + return f'/{API_BASE}preprints/invalid_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, osf_permissions.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' + + def test_invalid_preprint_id(self, app, invalid_url): + res = app.get(invalid_url, expect_errors=True) + assert res.status_code == 404 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..cd00750c0eb --- /dev/null +++ b/api_tests/preprints/views/test_preprint_institutions_relationship.py @@ -0,0 +1,304 @@ +import pytest +from api.base.settings.defaults import API_BASE +from osf_tests.factories import PreprintFactory, AuthUserFactory, InstitutionFactory +from osf.utils.permissions import READ, WRITE, ADMIN + + +@pytest.mark.django_db +class TestPreprintInstitutionsRelationship: + """Test suite for managing preprint institution relationships.""" + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def institution_A(self): + return InstitutionFactory() + + @pytest.fixture() + def institution_B(self): + return InstitutionFactory() + + @pytest.fixture() + def institution_C(self): + return InstitutionFactory() + + @pytest.fixture() + def institution_D(self): + return InstitutionFactory() + + @pytest.fixture() + def institution_E(self): + return InstitutionFactory() + + @pytest.fixture() + def institution_F(self): + return InstitutionFactory() + + @pytest.fixture() + def admin_with_institutional_affiliation(self, institution_A, institution_B, institution_C, preprint): + user = AuthUserFactory() + preprint.add_contributor(user, permissions=ADMIN) + user.add_or_update_affiliated_institution(institution_A) + user.add_or_update_affiliated_institution(institution_B) + user.add_or_update_affiliated_institution(institution_C) + return user + + @pytest.fixture() + def write_user_with_institutional_affiliation(self, institution_B, institution_C, institution_D, preprint): + user = AuthUserFactory() + preprint.add_contributor(user, permissions=WRITE) + user.add_or_update_affiliated_institution(institution_B) + user.add_or_update_affiliated_institution(institution_C) + user.add_or_update_affiliated_institution(institution_D) + return user + + @pytest.fixture() + def read_user_with_institutional_affiliation(self, institution_C, institution_D, institution_F, preprint): + user = AuthUserFactory() + preprint.add_contributor(user, permissions=READ) + user.add_or_update_affiliated_institution(institution_C) + user.add_or_update_affiliated_institution(institution_D) + user.add_or_update_affiliated_institution(institution_F) + return user + + @pytest.fixture() + def no_auth_with_institutional_affiliation(self, institution): + user = AuthUserFactory() + user.add_or_update_affiliated_institution(institution) + user.save() + return user + + @pytest.fixture() + def admin_without_institutional_affiliation(self, preprint): + user = AuthUserFactory() + preprint.add_contributor(user, permissions=ADMIN) + return user + + @pytest.fixture() + def institutions(self): + return [InstitutionFactory() for _ in range(3)] + + @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_unauthorized_user(self, app, user, url, institution_A): + """ + Test that unauthorized users cannot add institutions. + """ + update_institutions_payload = {'data': [{'type': 'institutions', 'id': institution_A._id}]} + res = app.put_json_api(url, update_institutions_payload, auth=user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_update_affiliated_institutions_add_read_user(self, app, read_user_with_institutional_affiliation, url, institution_A): + """ + Test that read users cannot add institutions. + """ + update_institutions_payload = {'data': [{'type': 'institutions', 'id': institution_A._id}]} + res = app.put_json_api(url, update_institutions_payload, auth=read_user_with_institutional_affiliation.auth, expect_errors=True) + assert res.status_code == 403 + + def test_update_affiliated_institutions_add_write_user(self, app, write_user_with_institutional_affiliation, url, institution_A, institution_B): + """ + Test that write users cannot add institutions. + """ + update_institutions_payload = {'data': [{'type': 'institutions', 'id': institution_A._id}]} + res = app.put_json_api(url, update_institutions_payload, auth=write_user_with_institutional_affiliation.auth, expect_errors=True) + assert res.status_code == 403 + + update_institutions_payload = {'data': [{'type': 'institutions', 'id': institution_B._id}]} + res = app.put_json_api(url, update_institutions_payload, auth=write_user_with_institutional_affiliation.auth) + assert res.status_code == 200 + + def test_update_affiliated_institutions_add_admin_without_affiliation(self, app, admin_without_institutional_affiliation, url, institution_A): + """ + Test that admins without affiliation cannot add institutions. + """ + update_institutions_payload = {'data': [{'type': 'institutions', 'id': institution_A._id}]} + res = app.put_json_api(url, update_institutions_payload, auth=admin_without_institutional_affiliation.auth, expect_errors=True) + assert res.status_code == 403 + assert res.json['errors'][0]['detail'] == f'User needs to be affiliated with {institution_A.name}' + + def test_update_affiliated_institutions_add_admin_with_affiliation(self, app, admin_with_institutional_affiliation, preprint, url, institution_A): + """ + Test that admins with affiliation can add institutions. + """ + update_institutions_payload = {'data': [{'type': 'institutions', 'id': institution_A._id}]} + res = app.put_json_api(url, update_institutions_payload, auth=admin_with_institutional_affiliation.auth) + assert res.status_code == 200 + preprint.reload() + assert institution_A in preprint.affiliated_institutions.all() + + log = preprint.logs.latest() + assert log.action == 'affiliated_institution_added' + assert log.params['institution'] == {'id': institution_A._id, 'name': institution_A.name} + + def test_update_affiliated_institutions_remove_unauthorized_user(self, app, user, preprint, url, institution_A): + """ + Test that unauthorized users cannot remove institutions. + """ + preprint.affiliated_institutions.add(institution_A) + 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 + + def test_update_affiliated_institutions_remove_read_user(self, app, read_user_with_institutional_affiliation, preprint, url, institution_A): + """ + Test that read users cannot remove institutions. + """ + preprint.affiliated_institutions.add(institution_A) + preprint.save() + update_institutions_payload = {'data': []} + res = app.put_json_api(url, update_institutions_payload, auth=read_user_with_institutional_affiliation.auth, expect_errors=True) + assert res.status_code == 403 + + def test_update_affiliated_institutions_remove_write_user(self, app, write_user_with_institutional_affiliation, preprint, url, institution_A): + """ + Test that write users cannot remove institutions. + """ + preprint.affiliated_institutions.add(institution_A) + preprint.save() + update_institutions_payload = {'data': []} + res = app.put_json_api(url, update_institutions_payload, auth=write_user_with_institutional_affiliation.auth, expect_errors=True) + assert res.status_code == 200 + + def test_update_affiliated_institutions_remove_admin_without_affiliation(self, app, admin_without_institutional_affiliation, preprint, url, institution_A): + """ + Test that admins without affiliation can remove institutions. + """ + preprint.affiliated_institutions.add(institution_A) + preprint.save() + update_institutions_payload = {'data': []} + res = app.put_json_api(url, update_institutions_payload, auth=admin_without_institutional_affiliation.auth) + assert res.status_code == 200 + + def test_update_affiliated_institutions_remove_admin_with_affiliation(self, app, admin_with_institutional_affiliation, preprint, url, institution_A): + """ + Test that admins with affiliation can remove institutions. + """ + preprint.affiliated_institutions.add(institution_A) + preprint.save() + update_institutions_payload = {'data': []} + res = app.put_json_api(url, update_institutions_payload, auth=admin_with_institutional_affiliation.auth) + assert res.status_code == 200 + preprint.reload() + assert institution_A not in preprint.affiliated_institutions.all() + + log = preprint.logs.latest() + assert log.action == 'affiliated_institution_removed' + assert log.params['institution'] == {'id': institution_A._id, 'name': institution_A.name} + + def test_preprint_institutions_list_get_unauthenticated(self, app, url): + """ + Test that unauthenticated users cannot retrieve the list of affiliated institutions for a preprint. + """ + res = app.get(url, expect_errors=True) + assert res.status_code == 200 + + def test_preprint_institutions_list_get_no_permissions(self, app, user, url): + """ + Test that users without permissions cannot retrieve the list of affiliated institutions for a preprint. + """ + res = app.get(url, auth=user.auth, expect_errors=True) + assert res.status_code == 200 + + def test_preprint_institutions_list_get_read_user(self, app, read_user_with_institutional_affiliation, preprint, url): + """ + Test that read users can retrieve the list of affiliated institutions for a preprint. + """ + preprint.is_public = False + preprint.save() + res = app.get(url, auth=read_user_with_institutional_affiliation.auth) + assert res.status_code == 200 + assert not res.json['data'] + + def test_preprint_institutions_list_get_write_user(self, app, write_user_with_institutional_affiliation, preprint, url): + """ + Test that write users can retrieve the list of affiliated institutions for a preprint. + """ + preprint.is_public = False + preprint.save() + res = app.get(url, auth=write_user_with_institutional_affiliation.auth) + assert res.status_code == 200 + assert not res.json['data'] + + def test_preprint_institutions_list_get_admin_without_affiliation(self, app, admin_without_institutional_affiliation, preprint, url): + """ + Test that admins without affiliation can retrieve the list of affiliated institutions for a preprint. + """ + preprint.is_public = False + preprint.save() + res = app.get(url, auth=admin_without_institutional_affiliation.auth) + assert res.status_code == 200 + assert not res.json['data'] + + def test_preprint_institutions_list_get_admin_with_affiliation(self, app, admin_with_institutional_affiliation, preprint, url, institution_A): + """ + Test that admins with affiliation can retrieve the list of affiliated institutions for a preprint. + """ + preprint.add_affiliated_institution(institution_A, admin_with_institutional_affiliation) + res = app.get(url, auth=admin_with_institutional_affiliation.auth) + assert res.status_code == 200 + assert res.json['data'][0]['id'] == institution_A._id + assert res.json['data'][0]['type'] == 'institutions' + + def test_post_affiliated_institutions(self, app, admin_with_institutional_affiliation, url, institutions): + """ + Test that POST method is not allowed for affiliated institutions. + """ + 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_affiliation.auth, expect_errors=True) + assert res.status_code == 405 + + def test_patch_affiliated_institutions(self, app, admin_with_institutional_affiliation, url, institutions): + """ + Test that PATCH method is not allowed for affiliated institutions. + """ + 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_affiliation.auth, expect_errors=True) + assert res.status_code == 405 + + def test_delete_affiliated_institution(self, app, admin_with_institutional_affiliation, preprint, url, institution_A): + """ + Test that DELETE method is not allowed for affiliated institutions. + """ + preprint.affiliated_institutions.add(institution_A) + preprint.save() + res = app.delete_json_api(url, {'data': [{'type': 'institutions', 'id': institution_A._id}]}, auth=admin_with_institutional_affiliation.auth, expect_errors=True) + assert res.status_code == 405 + + def test_add_multiple_institutions_affiliations(self, app, admin_with_institutional_affiliation, preprint, url, institutions): + """ + Test that admins with multiple affiliations can add them to a preprint. + """ + for institution in institutions: + admin_with_institutional_affiliation.add_or_update_affiliated_institution(institution) + admin_with_institutional_affiliation.save() + add_institutions_payload = {'data': [{'type': 'institutions', 'id': institution._id} for institution in institutions]} + res = app.put_json_api(url, add_institutions_payload, auth=admin_with_institutional_affiliation.auth) + assert res.status_code == 200 + preprint.reload() + assert preprint.affiliated_institutions.all().count() == 3 + + def test_remove_only_institutions_affiliations_that_user_has(self, app, admin_with_institutional_affiliation, preprint, url, institutions, institution_A): + """ + Test that 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_affiliation.add_or_update_affiliated_institution(institutions[0]) + admin_with_institutional_affiliation.add_or_update_affiliated_institution(institutions[1]) + update_institution_payload = {'data': [{'type': 'institutions', 'id': institution_A._id}]} + res = app.put_json_api(url, update_institution_payload, auth=admin_with_institutional_affiliation.auth) + assert res.status_code == 200 + assert preprint.affiliated_institutions.all().count() == 2 + assert institution_A in preprint.affiliated_institutions.all() + assert institutions[2] in preprint.affiliated_institutions.all() diff --git a/api_tests/preprints/views/test_preprint_list.py b/api_tests/preprints/views/test_preprint_list.py index 81fdde05f79..5fd8f224a01 100644 --- a/api_tests/preprints/views/test_preprint_list.py +++ b/api_tests/preprints/views/test_preprint_list.py @@ -26,6 +26,7 @@ AuthUserFactory, SubjectFactory, PreprintProviderFactory, + InstitutionFactory, ) from tests.base import ApiTestCase, capture_signals from website.project import signals as project_signals @@ -145,6 +146,7 @@ class TestPreprintList(ApiTestCase): def setUp(self): super().setUp() self.user = AuthUserFactory() + self.institution = InstitutionFactory() self.preprint = PreprintFactory(creator=self.user) self.url = f'/{API_BASE}preprints/' @@ -184,6 +186,19 @@ def test_withdrawn_preprints_list(self): assert pp._id not in user_res_ids assert pp._id in mod_res_ids + def test_return_affiliated_institutions(self): + """ + Confirmation test for the the new preprint affiliated institutions feature + """ + self.preprint.affiliated_institutions.add(self.institution) + res = self.app.get(self.url) + assert len(res.json['data']) == 1 + assert res.status_code == 200 + assert res.content_type == 'application/vnd.api+json' + relationship_link = res.json['data'][0]['relationships']['affiliated_institutions']['links']['related']['href'] + assert f'/v2/preprints/{self.preprint._id}/institutions/' in relationship_link + relationship_link = res.json['data'][0]['relationships']['affiliated_institutions']['links']['self']['href'] + assert f'/v2/preprints/{self.preprint._id}/relationships/institutions/' in relationship_link class TestPreprintsListFiltering(PreprintsListFilteringMixin): diff --git a/osf/migrations/0023_preprint_affiliated_institutions.py b/osf/migrations/0023_preprint_affiliated_institutions.py new file mode 100644 index 00000000000..cdfecc03858 --- /dev/null +++ b/osf/migrations/0023_preprint_affiliated_institutions.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.17 on 2024-07-18 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0022_alter_abstractnode_subjects_alter_abstractnode_tags_and_more'), + ] + + 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 ba039cc8651..17c35db04a6 100644 --- a/osf/models/mixins.py +++ b/osf/models/mixins.py @@ -297,7 +297,7 @@ class AffiliatedInstitutionMixin(models.Model): affiliated_institutions = models.ManyToManyField('Institution', related_name='nodes') - def add_affiliated_institution(self, inst, user, save=False, log=True): + def add_affiliated_institution(self, inst, user, log=True): if not user.is_affiliated_with_institution(inst): raise UserNotAffiliatedError(f'User is not affiliated with {inst.name}') if not self.is_affiliated_with_institution(inst): diff --git a/osf/models/preprint.py b/osf/models/preprint.py index 6f92e140bff..ff7a5f88c5e 100644 --- a/osf/models/preprint.py +++ b/osf/models/preprint.py @@ -25,7 +25,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 @@ -108,7 +108,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 @@ -141,6 +141,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 2abbadec3d2..b846d851f7f 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, diff --git a/osf_tests/metadata/test_osf_gathering.py b/osf_tests/metadata/test_osf_gathering.py index 52289367ccc..7bd72770aba 100644 --- a/osf_tests/metadata/test_osf_gathering.py +++ b/osf_tests/metadata/test_osf_gathering.py @@ -538,6 +538,7 @@ def test_gather_affiliated_institutions(self): institution_iri = URIRef(institution.ror_uri) self.user__admin.add_or_update_affiliated_institution(institution) self.project.add_affiliated_institution(institution, self.user__admin) + self.preprint.add_affiliated_institution(institution, self.user__admin) assert_triples(osf_gathering.gather_affiliated_institutions(self.projectfocus), { (self.projectfocus.iri, OSF.affiliation, institution_iri), (institution_iri, RDF.type, DCTERMS.Agent), @@ -559,6 +560,15 @@ def test_gather_affiliated_institutions(self): assert_triples(osf_gathering.gather_affiliated_institutions(self.registrationfocus), set()) # focus: file assert_triples(osf_gathering.gather_affiliated_institutions(self.filefocus), set()) + # focus: preprint + assert_triples(osf_gathering.gather_affiliated_institutions(self.preprintfocus), { + (self.preprintfocus.iri, OSF.affiliation, institution_iri), + (institution_iri, RDF.type, DCTERMS.Agent), + (institution_iri, RDF.type, FOAF.Organization), + (institution_iri, FOAF.name, Literal(institution.name)), + (institution_iri, DCTERMS.identifier, Literal(institution.identifier_domain)), + (institution_iri, DCTERMS.identifier, Literal(institution.ror_uri)), + }) def test_gather_funding(self): # focus: project diff --git a/osf_tests/test_institutional_affiliation.py b/osf_tests/test_institutional_affiliation.py new file mode 100644 index 00000000000..86bb6cebff6 --- /dev/null +++ b/osf_tests/test_institutional_affiliation.py @@ -0,0 +1,55 @@ +import pytest +from osf_tests.factories import ( + PreprintFactory, + UserFactory, + InstitutionFactory, +) +from osf.exceptions import UserNotAffiliatedError + + +@pytest.mark.django_db +class TestPreprintInstitutionalAffiliation: + """ + Tests for preprint model to handle updating InstitutionalAffiliationMixin + """ + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + @pytest.fixture() + def user(self, institution): + user = UserFactory() + user.add_or_update_affiliated_institution(institution) + return user + + @pytest.fixture() + def user_without_affiliation(self): + return UserFactory() + + @pytest.fixture() + def preprint(self, user): + preprint = PreprintFactory() + preprint.add_permission(user, 'admin') + return preprint + + def test_remove_nonexistent_affiliation(self, preprint, institution, user): + assert not preprint.remove_affiliated_institution(institution, user) + + def test_add_affiliated_institution_unaffiliated_user(self, preprint, institution, user_without_affiliation): + with pytest.raises(UserNotAffiliatedError): + preprint.add_affiliated_institution(institution, user_without_affiliation) + + assert not preprint.is_affiliated_with_institution(institution) + + def test_add_and_remove_affiliated_institution(self, preprint, institution, user): + preprint.add_affiliated_institution(institution, user) + assert preprint.is_affiliated_with_institution(institution) + + was_removed = preprint.remove_affiliated_institution(institution, user) + assert was_removed + assert not preprint.is_affiliated_with_institution(institution) + + def test_permission_errors_during_affiliation_update(self, preprint, institution, user_without_affiliation): + with pytest.raises(UserNotAffiliatedError): + preprint.add_affiliated_institution(institution, user_without_affiliation)