diff --git a/api/nodes/views.py b/api/nodes/views.py index 93879e2d40a..54b16985d95 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -125,7 +125,7 @@ RegistrationSerializer, RegistrationCreateSerializer, ) -from api.requests.permissions import NodeRequestPermission +from api.requests.permissions import NodeRequestPermission, InstitutionalAdminRequestType from api.requests.serializers import NodeRequestSerializer, NodeRequestCreateSerializer from api.requests.views import NodeRequestMixin from api.resources import annotations as resource_annotations @@ -1730,7 +1730,6 @@ class NodeInstitutionsRelationship(JSONAPIBaseView, generics.RetrieveUpdateDestr required_write_scopes = [CoreScopes.NODE_BASE_WRITE] serializer_class = NodeInstitutionsRelationshipSerializer parser_classes = (JSONAPIRelationshipParser, JSONAPIRelationshipParserForRegularJSON) - view_category = 'nodes' view_name = 'node-relationships-institutions' @@ -2239,6 +2238,7 @@ class NodeRequestListCreate(JSONAPIBaseView, generics.ListCreateAPIView, ListFil drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, NodeRequestPermission, + InstitutionalAdminRequestType, ) required_read_scopes = [CoreScopes.NODE_REQUESTS_READ] diff --git a/api/requests/permissions.py b/api/requests/permissions.py index 09c97f012ba..26e60e89e1c 100644 --- a/api/requests/permissions.py +++ b/api/requests/permissions.py @@ -1,11 +1,15 @@ from rest_framework import permissions as drf_permissions from api.base.utils import get_user_auth -from osf.models.action import NodeRequestAction, PreprintRequestAction +from osf.models import ( + Node, + NodeRequestAction, + PreprintRequestAction, + Preprint, + Institution, +) from osf.models.mixins import NodeRequestableMixin, PreprintRequestableMixin -from osf.models.node import Node -from osf.models.preprint import Preprint -from osf.utils.workflows import DefaultTriggers +from osf.utils.workflows import DefaultTriggers, NodeRequestTypes from osf.utils import permissions as osf_permissions @@ -52,8 +56,35 @@ def has_object_permission(self, request, view, obj): # Requesters may not be contributors # Requesters may edit their comment or submit their request return is_requester and auth.user not in node.contributors + + +class InstitutionalAdminRequestType(drf_permissions.BasePermission): + """ + Permission class for handling object permissions related to Node requests and actions. + """ + + def has_object_permission(self, request, view, obj): + # Skip if not institutional_request request_type + request_type = request.data.get('request_type') + if request_type != NodeRequestTypes.INSTITUTIONAL_REQUEST.value: + return True + + auth = get_user_auth(request) + if not auth.user: return False + # Extract relevant request data + institution_id = request.data.get('institution') + if not institution_id: + return False + + try: + institution = Institution.objects.get(_id=institution_id) + except Institution.DoesNotExist: + return False + + return auth.user.is_institutional_admin(institution) + class PreprintRequestPermission(drf_permissions.BasePermission): def has_object_permission(self, request, view, obj): diff --git a/api/requests/serializers.py b/api/requests/serializers.py index 71ef190b50d..628d5c3b00c 100644 --- a/api/requests/serializers.py +++ b/api/requests/serializers.py @@ -4,10 +4,21 @@ from api.base.exceptions import Conflict from api.base.utils import absolute_reverse, get_user_auth -from api.base.serializers import JSONAPISerializer, LinksField, VersionedDateTimeField, RelationshipField -from osf.models import NodeRequest, PreprintRequest -from osf.utils.workflows import DefaultStates, RequestTypes +from api.base.serializers import ( + JSONAPISerializer, + LinksField, + VersionedDateTimeField, + RelationshipField, +) +from osf.models import ( + NodeRequest, + PreprintRequest, + Institution, +) +from osf.utils.workflows import DefaultStates, RequestTypes, NodeRequestTypes from osf.utils import permissions as osf_permissions +from website import settings +from website.mails import send_mail, NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST class RequestSerializer(JSONAPISerializer): @@ -66,8 +77,19 @@ class Meta: source='target__guids___id', ) + requested_permissions = ser.ChoiceField( + choices=osf_permissions.API_CONTRIBUTOR_PERMISSIONS, + required=False, + ) + def get_target_url(self, obj): - return absolute_reverse('nodes:node-detail', kwargs={'node_id': obj.target._id, 'version': self.context['request'].parser_context['kwargs']['version']}) + return absolute_reverse( + 'nodes:node-detail', + kwargs={ + 'node_id': obj.target._id, + 'version': self.context['request'].parser_context['kwargs']['version'], + }, + ) class RegistrationRequestSerializer(RequestSerializer): @@ -89,8 +111,20 @@ def get_target_url(self, obj): }, ) + class NodeRequestCreateSerializer(NodeRequestSerializer): - request_type = ser.ChoiceField(required=True, choices=RequestTypes.choices()) + request_type = ser.ChoiceField(required=True, choices=NodeRequestTypes.choices()) + institution = RelationshipField( + related_view='institutions:institution-detail', + related_view_kwargs={'institution_id': ''}, + required=False, + ) + + def to_internal_value(self, data): + """ + Make RelationshipField pass data. + """ + return data def create(self, validated_data): auth = get_user_auth(self.context['request']) @@ -105,26 +139,60 @@ def create(self, validated_data): raise exceptions.PermissionDenied('You cannot request access to a node you contribute to.') raise - comment = validated_data.pop('comment', '') - request_type = validated_data.pop('request_type', None) - - if request_type != RequestTypes.ACCESS.value: + request_type = validated_data.get('request_type', None) + if not request_type: raise exceptions.ValidationError('You must specify a valid request_type.') + elif request_type == NodeRequestTypes.ACCESS.value: + return self.make_node_access_request(node, validated_data) + elif request_type == NodeRequestTypes.INSTITUTIONAL_REQUEST.value: + return self.make_node_institutional_access_request(node, validated_data) + else: + raise NotImplementedError(f'{request_type} request_type not implemented') + + def make_node_access_request(self, node, validated_data): + return self._create_node_request(node, validated_data) + + def make_node_institutional_access_request(self, node, validated_data): + node_request = self._create_node_request(node, validated_data) + sender = self.context['request'].user + institution_id = validated_data.get('institution') + institution = Institution.objects.get(_id=institution_id) + send_mail( + to_addr=node.creator.username, + mail=NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST, + user=node.creator, + **{ + 'sender': sender, + 'recipient': node.creator, + 'comment': validated_data['comment'], + 'institution': institution, + 'osf_url': settings.DOMAIN, + 'node': node_request.target, + }, + ) + return node_request + def _create_node_request(self, node, validated_data): + creator = self.context['request'].user + request_type = validated_data['request_type'] try: node_request = NodeRequest.objects.create( target=node, - creator=auth.user, - comment=comment, + creator=creator, + comment=validated_data['comment'], machine_state=DefaultStates.INITIAL.value, request_type=request_type, + requested_permissions=validated_data.get('requested_permissions'), ) node_request.save() except IntegrityError: raise Conflict(f'Users may not have more than one {request_type} request per node.') - node_request.run_submit(auth.user) + + node_request.run_submit(creator) + return node_request + class PreprintRequestSerializer(RequestSerializer): class Meta: type_ = 'preprint-requests' diff --git a/api_tests/registries_moderation/test_submissions.py b/api_tests/registries_moderation/test_submissions.py index a7e0b3dbb6c..532f71d93fe 100644 --- a/api_tests/registries_moderation/test_submissions.py +++ b/api_tests/registries_moderation/test_submissions.py @@ -4,7 +4,7 @@ from api.base.settings.defaults import API_BASE from api.providers.workflows import Workflows -from osf.utils.workflows import RequestTypes, RegistrationModerationTriggers, RegistrationModerationStates +from osf.utils.workflows import NodeRequestTypes, RegistrationModerationTriggers, RegistrationModerationStates from osf_tests.factories import ( @@ -66,7 +66,7 @@ def registration_with_withdraw_request(self, provider): registration = RegistrationFactory(provider=provider) NodeRequest.objects.create( - request_type=RequestTypes.WITHDRAWAL.value, + request_type=NodeRequestTypes.WITHDRAWAL.value, target=registration, creator=registration.creator ) @@ -75,7 +75,7 @@ def registration_with_withdraw_request(self, provider): @pytest.fixture() def access_request(self, provider): - request = NodeRequestFactory(request_type=RequestTypes.ACCESS.value) + request = NodeRequestFactory(request_type=NodeRequestTypes.ACCESS.value) request.target.provider = provider request.target.save() diff --git a/api_tests/requests/views/test_request_list_create.py b/api_tests/requests/views/test_node_request_list.py similarity index 56% rename from api_tests/requests/views/test_request_list_create.py rename to api_tests/requests/views/test_node_request_list.py index fdd96b2cd02..e41295f07a6 100644 --- a/api_tests/requests/views/test_request_list_create.py +++ b/api_tests/requests/views/test_node_request_list.py @@ -1,10 +1,19 @@ from unittest import mock import pytest -from osf.utils import workflows from api.base.settings.defaults import API_BASE -from api_tests.requests.mixins import NodeRequestTestMixin, PreprintRequestTestMixin -from osf_tests.factories import NodeFactory, NodeRequestFactory +from api_tests.requests.mixins import NodeRequestTestMixin + +from osf_tests.factories import ( + NodeFactory, + NodeRequestFactory, + InstitutionFactory, + AuthUserFactory +) +from osf.utils.workflows import DefaultStates, NodeRequestTypes + +from website.mails import NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST + @pytest.mark.django_db class TestNodeRequestListCreate(NodeRequestTestMixin): @@ -18,7 +27,7 @@ def create_payload(self): 'data': { 'attributes': { 'comment': 'ASDFG', - 'request_type': 'access' + 'request_type': NodeRequestTypes.ACCESS.value }, 'type': 'node-requests' } @@ -112,8 +121,8 @@ def test_filter_by_machine_state(self, app, project, noncontrib, url, admin, nod initial_node_request = NodeRequestFactory( creator=noncontrib, target=project, - request_type=workflows.RequestTypes.ACCESS.value, - machine_state=workflows.DefaultStates.INITIAL.value + request_type=NodeRequestTypes.ACCESS.value, + machine_state=DefaultStates.INITIAL.value ) filtered_url = f'{url}?filter[machine_state]=pending' res = app.get(filtered_url, auth=admin.auth) @@ -122,68 +131,101 @@ def test_filter_by_machine_state(self, app, project, noncontrib, url, admin, nod assert initial_node_request._id not in ids assert node_request.machine_state == 'pending' and node_request._id in ids + @pytest.mark.django_db -class TestPreprintRequestListCreate(PreprintRequestTestMixin): - def url(self, preprint): - return f'/{API_BASE}preprints/{preprint._id}/requests/' +class TestNodeRequestListInstitutionalAccess(NodeRequestTestMixin): @pytest.fixture() - def create_payload(self): + def url(self, project): + return f'/{API_BASE}nodes/{project._id}/requests/' + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + @pytest.fixture() + def institutional_admin(self, institution): + admin_user = AuthUserFactory() + institution.get_group('institutional_admins').user_set.add(admin_user) + return admin_user + + @pytest.fixture() + def create_payload(self, institution): return { 'data': { 'attributes': { - 'comment': 'ASDFG', - 'request_type': 'withdrawal' + 'comment': 'Wanna Philly Philly?', + 'request_type': NodeRequestTypes.INSTITUTIONAL_REQUEST.value, + }, + 'relationships': { + 'institution': { + 'data': { + 'id': institution._id, + 'type': 'institutions' + } + } }, - 'type': 'preprint-requests' + 'type': 'node-requests' } } - def test_noncontrib_cannot_submit(self, app, noncontrib, create_payload, pre_mod_preprint, post_mod_preprint, none_mod_preprint): - for preprint in [pre_mod_preprint, post_mod_preprint, none_mod_preprint]: - res = app.post_json_api(self.url(preprint), create_payload, auth=noncontrib.auth, expect_errors=True) - assert res.status_code == 403 - - def test_unauth_cannot_submit(self, app, create_payload, pre_mod_preprint, post_mod_preprint, none_mod_preprint): - for preprint in [pre_mod_preprint, post_mod_preprint, none_mod_preprint]: - res = app.post_json_api(self.url(preprint), create_payload, expect_errors=True) - assert res.status_code == 401 - - def test_write_contributor_cannot_submit(self, app, write_contrib, create_payload, pre_mod_preprint, post_mod_preprint, none_mod_preprint): - for preprint in [pre_mod_preprint, post_mod_preprint, none_mod_preprint]: - res = app.post_json_api(self.url(preprint), create_payload, auth=write_contrib.auth, expect_errors=True) - assert res.status_code == 403 - - def test_admin_can_submit(self, app, admin, create_payload, pre_mod_preprint, post_mod_preprint, none_mod_preprint): - for preprint in [pre_mod_preprint, post_mod_preprint, none_mod_preprint]: - res = app.post_json_api(self.url(preprint), create_payload, auth=admin.auth) - assert res.status_code == 201 - - def test_admin_can_view_requests(self, app, admin, pre_request, post_request, none_request): - for request in [pre_request, post_request, none_request]: - res = app.get(self.url(request.target), auth=admin.auth) - assert res.status_code == 200 - assert res.json['data'][0]['id'] == request._id - - def test_noncontrib_and_write_contrib_cannot_view_requests(self, app, noncontrib, write_contrib, pre_request, post_request, none_request): - for request in [pre_request, post_request, none_request]: - for user in [noncontrib, write_contrib]: - res = app.get(self.url(request.target), auth=user.auth, expect_errors=True) - assert res.status_code == 403 - - def test_unauth_cannot_view_requests(self, app, noncontrib, write_contrib, pre_request, post_request, none_request): - for request in [pre_request, post_request, none_request]: - res = app.get(self.url(request.target), expect_errors=True) - assert res.status_code == 401 - - def test_requester_cannot_submit_again(self, app, admin, create_payload, pre_mod_preprint, pre_request): - res = app.post_json_api(self.url(pre_mod_preprint), create_payload, auth=admin.auth, expect_errors=True) - assert res.status_code == 409 - assert res.json['errors'][0]['detail'] == 'Users may not have more than one withdrawal request per preprint.' + def test_institutional_admin_can_make_institutional_request(self, app, project, institutional_admin, url, create_payload): + """ + Test that an institutional admin can make an institutional access request. + """ + res = app.post_json_api(url, create_payload, auth=institutional_admin.auth) + assert res.status_code == 201 + + # Verify the NodeRequest object is created + node_request = project.requests.get(creator=institutional_admin) + assert node_request.request_type == NodeRequestTypes.INSTITUTIONAL_REQUEST.value + assert node_request.comment == 'Wanna Philly Philly?' + assert node_request.machine_state == DefaultStates.PENDING.value + + def test_non_admin_cant_make_institutional_request(self, app, project, noncontrib, url, create_payload): + """ + Test that a non-institutional admin cannot make an institutional access request. + """ + res = app.post_json_api(url, create_payload, auth=noncontrib.auth, expect_errors=True) + assert res.status_code == 403 + assert 'You do not have permission to perform this action' in res.json['errors'][0]['detail'] - @pytest.mark.skip('TODO: IN-284 -- add emails') - @mock.patch('website.reviews.listeners.mails.send_mail') - def test_email_sent_to_moderators_on_submit(self, mock_mail, app, admin, create_payload, moderator, post_mod_preprint): - res = app.post_json_api(self.url(post_mod_preprint), create_payload, auth=admin.auth) + def test_institutional_admin_can_add_requested_permission(self, app, project, institutional_admin, url, create_payload): + """ + Test that an institutional admin can make an institutional access request with requested_permissions. + """ + create_payload['data']['attributes']['requested_permissions'] = 'admin' + + res = app.post_json_api(url, create_payload, auth=institutional_admin.auth) assert res.status_code == 201 - assert mock_mail.call_count == 1 + + # Verify the NodeRequest object is created with the correct requested_permissions + node_request = project.requests.get(creator=institutional_admin) + assert node_request.request_type == NodeRequestTypes.INSTITUTIONAL_REQUEST.value + assert node_request.requested_permissions == 'admin' + + @mock.patch('api.requests.serializers.send_mail') + def test_email_sent_on_institutional_request(self, mock_mail, app, project, institutional_admin, url, + create_payload, institution): + """ + Test that an email is sent to the appropriate recipients when an institutional access request is made. + """ + res = app.post_json_api(url, create_payload, auth=institutional_admin.auth) + assert res.status_code == 201 + + # Check that an email is sent + assert mock_mail.call_count > 0 + + mock_mail.assert_called_with( + to_addr=project.creator.username, + mail=NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST, + user=project.creator, + **{ + 'sender': institutional_admin, + 'recipient': project.creator, + 'comment': create_payload['data']['attributes']['comment'], + 'institution': institution, + 'osf_url': mock.ANY, + 'node': project, + } + ) diff --git a/api_tests/requests/views/test_preprint_request_list.py b/api_tests/requests/views/test_preprint_request_list.py new file mode 100644 index 00000000000..d23736aa312 --- /dev/null +++ b/api_tests/requests/views/test_preprint_request_list.py @@ -0,0 +1,72 @@ +from unittest import mock +import pytest + +from api.base.settings.defaults import API_BASE +from api_tests.requests.mixins import PreprintRequestTestMixin + + +@pytest.mark.django_db +class TestPreprintRequestListCreate(PreprintRequestTestMixin): + def url(self, preprint): + return f'/{API_BASE}preprints/{preprint._id}/requests/' + + @pytest.fixture() + def create_payload(self): + return { + 'data': { + 'attributes': { + 'comment': 'ASDFG', + 'request_type': 'withdrawal' + }, + 'type': 'preprint-requests' + } + } + + def test_noncontrib_cannot_submit(self, app, noncontrib, create_payload, pre_mod_preprint, post_mod_preprint, none_mod_preprint): + for preprint in [pre_mod_preprint, post_mod_preprint, none_mod_preprint]: + res = app.post_json_api(self.url(preprint), create_payload, auth=noncontrib.auth, expect_errors=True) + assert res.status_code == 403 + + def test_unauth_cannot_submit(self, app, create_payload, pre_mod_preprint, post_mod_preprint, none_mod_preprint): + for preprint in [pre_mod_preprint, post_mod_preprint, none_mod_preprint]: + res = app.post_json_api(self.url(preprint), create_payload, expect_errors=True) + assert res.status_code == 401 + + def test_write_contributor_cannot_submit(self, app, write_contrib, create_payload, pre_mod_preprint, post_mod_preprint, none_mod_preprint): + for preprint in [pre_mod_preprint, post_mod_preprint, none_mod_preprint]: + res = app.post_json_api(self.url(preprint), create_payload, auth=write_contrib.auth, expect_errors=True) + assert res.status_code == 403 + + def test_admin_can_submit(self, app, admin, create_payload, pre_mod_preprint, post_mod_preprint, none_mod_preprint): + for preprint in [pre_mod_preprint, post_mod_preprint, none_mod_preprint]: + res = app.post_json_api(self.url(preprint), create_payload, auth=admin.auth) + assert res.status_code == 201 + + def test_admin_can_view_requests(self, app, admin, pre_request, post_request, none_request): + for request in [pre_request, post_request, none_request]: + res = app.get(self.url(request.target), auth=admin.auth) + assert res.status_code == 200 + assert res.json['data'][0]['id'] == request._id + + def test_noncontrib_and_write_contrib_cannot_view_requests(self, app, noncontrib, write_contrib, pre_request, post_request, none_request): + for request in [pre_request, post_request, none_request]: + for user in [noncontrib, write_contrib]: + res = app.get(self.url(request.target), auth=user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_unauth_cannot_view_requests(self, app, noncontrib, write_contrib, pre_request, post_request, none_request): + for request in [pre_request, post_request, none_request]: + res = app.get(self.url(request.target), expect_errors=True) + assert res.status_code == 401 + + def test_requester_cannot_submit_again(self, app, admin, create_payload, pre_mod_preprint, pre_request): + res = app.post_json_api(self.url(pre_mod_preprint), create_payload, auth=admin.auth, expect_errors=True) + assert res.status_code == 409 + assert res.json['errors'][0]['detail'] == 'Users may not have more than one withdrawal request per preprint.' + + @pytest.mark.skip('TODO: IN-284 -- add emails') + @mock.patch('website.reviews.listeners.mails.send_mail') + def test_email_sent_to_moderators_on_submit(self, mock_mail, app, admin, create_payload, moderator, post_mod_preprint): + res = app.post_json_api(self.url(post_mod_preprint), create_payload, auth=admin.auth) + assert res.status_code == 201 + assert mock_mail.call_count == 1 diff --git a/osf/metrics/reporters/institutional_users.py b/osf/metrics/reporters/institutional_users.py index 512472a3d96..e34875d4b28 100644 --- a/osf/metrics/reporters/institutional_users.py +++ b/osf/metrics/reporters/institutional_users.py @@ -68,7 +68,7 @@ def __post_init__(self): private_project_count=self._private_project_queryset().count(), public_registration_count=self._public_registration_queryset().count(), embargoed_registration_count=self._embargoed_registration_queryset().count(), - public_file_count=self._public_osfstorage_file_count(), + public_file_count=self._public_osfstorage_file_queryset().count(), published_preprint_count=self._published_preprint_queryset().count(), storage_byte_count=self._storage_byte_count(), ) @@ -127,7 +127,7 @@ def _published_preprint_queryset(self): .exclude(spam_status=SpamStatus.SPAM) ) - def _public_osfstorage_file_querysets(self): + def _public_osfstorage_file_queryset(self): _target_node_q = Q( # any public project, registration, project component, or registration component target_object_id__in=self._node_queryset().filter(is_public=True).values('pk'), @@ -137,40 +137,23 @@ def _public_osfstorage_file_querysets(self): target_object_id__in=self._published_preprint_queryset().values('pk'), target_content_type=ContentType.objects.get_for_model(osfdb.Preprint), ) - return ( # split into two queries to avoid a parallel sequence scan on BFN - OsfStorageFile.objects - .filter( - created__lt=self.before_datetime, - deleted__isnull=True, - purged__isnull=True, - ) - .filter(_target_node_q), + return ( OsfStorageFile.objects .filter( created__lt=self.before_datetime, deleted__isnull=True, purged__isnull=True, ) - .filter(_target_preprint_q) - ) - - def _public_osfstorage_file_count(self): - return sum( - _target_queryset.count() for _target_queryset - in self._public_osfstorage_file_querysets() + .filter(_target_node_q | _target_preprint_q) ) def _storage_byte_count(self): - return sum( - osfdb.FileVersion.objects.filter( - size__gt=0, - created__lt=self.before_datetime, - purged__isnull=True, - basefilenode__in=_target_queryset, - ).aggregate(storage_bytes=Sum('size', default=0))['storage_bytes'] - for _target_queryset - in self._public_osfstorage_file_querysets() - ) + return osfdb.FileVersion.objects.filter( + size__gt=0, + created__lt=self.before_datetime, + purged__isnull=True, + basefilenode__in=self._public_osfstorage_file_queryset(), + ).aggregate(storage_bytes=Sum('size', default=0))['storage_bytes'] def _get_last_active(self): end_date = self.yearmonth.month_end() diff --git a/osf/migrations/0025_noderequest_requested_permissions_and_more.py b/osf/migrations/0025_noderequest_requested_permissions_and_more.py new file mode 100644 index 00000000000..9d8a41d3a2f --- /dev/null +++ b/osf/migrations/0025_noderequest_requested_permissions_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.13 on 2024-12-02 19:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0024_institution_link_to_external_reports_archive'), + ] + + operations = [ + migrations.AddField( + model_name='noderequest', + name='requested_permissions', + field=models.CharField(blank=True, choices=[('read', 'read'), ('write', 'write'), ('admin', 'admin')], max_length=31, null=True), + ), + migrations.AlterField( + model_name='noderequest', + name='request_type', + field=models.CharField(choices=[('access', 'Access'), ('withdrawal', 'Withdrawal'), ('institutional_request', 'Institutional_Request')], max_length=31), + ), + ] diff --git a/osf/models/request.py b/osf/models/request.py index d91837cbc7c..6bc11eecf77 100644 --- a/osf/models/request.py +++ b/osf/models/request.py @@ -1,8 +1,8 @@ from django.db import models - from .base import BaseModel, ObjectIDMixin -from osf.utils.workflows import RequestTypes +from osf.utils.workflows import RequestTypes, NodeRequestTypes from .mixins import NodeRequestableMixin, PreprintRequestableMixin +from osf.utils.permissions import API_CONTRIBUTOR_PERMISSIONS class AbstractRequest(BaseModel, ObjectIDMixin): @@ -21,7 +21,14 @@ def target(self): class NodeRequest(AbstractRequest, NodeRequestableMixin): """ Request for Node Access """ + request_type = models.CharField(max_length=31, choices=NodeRequestTypes.choices()) target = models.ForeignKey('AbstractNode', related_name='requests', on_delete=models.CASCADE) + requested_permissions = models.CharField( + max_length=31, + choices=((perm.lower(), perm) for perm in API_CONTRIBUTOR_PERMISSIONS), + null=True, + blank=True + ) class PreprintRequest(AbstractRequest, PreprintRequestableMixin): diff --git a/osf/models/user.py b/osf/models/user.py index bb0f97f91a9..411381b0687 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -644,6 +644,10 @@ def osf_groups(self): OSFGroup = apps.get_model('osf.OSFGroup') return get_objects_for_user(self, 'member_group', OSFGroup, with_superuser=False) + def is_institutional_admin(self, institution): + group_name = institution.format_group('institutional_admins') + return self.groups.filter(name=group_name).exists() + def group_role(self, group): """ For the given OSFGroup, return the user's role - either member or manager diff --git a/osf/utils/workflows.py b/osf/utils/workflows.py index 43d21659bde..b054de25452 100644 --- a/osf/utils/workflows.py +++ b/osf/utils/workflows.py @@ -515,3 +515,9 @@ def db_name(self): class RequestTypes(ChoiceEnum): ACCESS = 'access' WITHDRAWAL = 'withdrawal' + +@unique +class NodeRequestTypes(ChoiceEnum): + ACCESS = 'access' + WITHDRAWAL = 'withdrawal' + INSTITUTIONAL_REQUEST = 'institutional_request' diff --git a/website/mails/mails.py b/website/mails/mails.py index afca9e78f03..d6d79621cf1 100644 --- a/website/mails/mails.py +++ b/website/mails/mails.py @@ -595,3 +595,8 @@ def get_english_article(word): 'addons_boa_job_failure', subject='Your Boa job has failed' ) + +NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST = Mail( + 'node_request_institutional_access_request', + subject='Institutional Access Project Request' +) diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 91e3c1bacc6..0467ef3c166 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -446,7 +446,6 @@ class CeleryConfig: 'osf.management.commands.daily_reporters_go', 'osf.management.commands.monthly_reporters_go', 'osf.management.commands.ingest_cedar_metadata_templates', - 'osf.metrics.reporters', } med_pri_modules = { diff --git a/website/templates/emails/node_request_institutional_access_request.html.mako b/website/templates/emails/node_request_institutional_access_request.html.mako new file mode 100644 index 00000000000..67634e364c2 --- /dev/null +++ b/website/templates/emails/node_request_institutional_access_request.html.mako @@ -0,0 +1,40 @@ +<%inherit file="notify_base.mako" /> + +<%def name="content()"> + + + <%!from website import settings%> + Hello ${recipient.fullname}, +

+ ${sender.fullname} (View Profile) has requested access to + ${node.title}. +

+ % if comment: +

+ ${comment} +

+ % endif +

+ To review the request, click here to allow or deny access + and configure permissions. +

+

+ This request is being sent to you because your project has the "Request Access" feature enabled. + This allows potential collaborators to request access to your project. To disable this feature, click + here. +

+

+ Sincerely,
+ The OSF Team +

+

+ Want more information? Visit ${settings.DOMAIN} to learn about the OSF, + or https://cos.io/ for information about its supporting organization, + the Center for Open Science. +

+

+ Questions? Email ${settings.OSF_CONTACT_EMAIL} +

+ + +