Skip to content

Commit

Permalink
add NodeRequest improvements for Institutional Access
Browse files Browse the repository at this point in the history
  • Loading branch information
Johnetordoff authored and John Tordoff committed Dec 12, 2024
1 parent 9831d86 commit 73e32d0
Show file tree
Hide file tree
Showing 12 changed files with 543 additions and 211 deletions.
3 changes: 2 additions & 1 deletion api/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
RegistrationSerializer,
RegistrationCreateSerializer,
)
from api.requests.permissions import NodeRequestPermission
from api.requests.permissions import NodeRequestPermission, InstitutionalAdminRequestTypePermission
from api.requests.serializers import NodeRequestSerializer, NodeRequestCreateSerializer
from api.requests.views import NodeRequestMixin
from api.resources import annotations as resource_annotations
Expand Down Expand Up @@ -2239,6 +2239,7 @@ class NodeRequestListCreate(JSONAPIBaseView, generics.ListCreateAPIView, ListFil
drf_permissions.IsAuthenticatedOrReadOnly,
base_permissions.TokenHasScope,
NodeRequestPermission,
InstitutionalAdminRequestTypePermission,
)

required_read_scopes = [CoreScopes.NODE_REQUESTS_READ]
Expand Down
38 changes: 34 additions & 4 deletions api/requests/permissions.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -52,8 +56,34 @@ 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 InstitutionalAdminRequestTypePermission(drf_permissions.BasePermission):
"""
Permission class for handling object permissions related to Node requests and actions.
"""

def has_permission(self, request, view):
# 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

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):
Expand Down
124 changes: 112 additions & 12 deletions api/requests/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,24 @@

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,
OSFUser,
)
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

from rest_framework.exceptions import PermissionDenied, ValidationError


class RequestSerializer(JSONAPISerializer):
Expand Down Expand Up @@ -56,6 +70,8 @@ def create(self, validated_data):
raise NotImplementedError()

class NodeRequestSerializer(RequestSerializer):
request_type = ser.ChoiceField(read_only=True, required=False, choices=NodeRequestTypes.choices())

class Meta:
type_ = 'node-requests'

Expand All @@ -66,6 +82,13 @@ class Meta:
source='target__guids___id',
)

requested_permissions = ser.ChoiceField(
help_text='These are supposed to represent the default permission suggested when the Node admin sees users '
'listed in an `Request Access` list.',
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']})

Expand All @@ -89,11 +112,40 @@ def get_target_url(self, obj):
},
)


class NodeRequestCreateSerializer(NodeRequestSerializer):
request_type = ser.ChoiceField(required=True, choices=RequestTypes.choices())
request_type = ser.ChoiceField(read_only=False, required=False, choices=NodeRequestTypes.choices())
message_recipient = RelationshipField(
help_text='An optional user who will recieve a an email explaining the nature of the request.',
required=False,
related_view='users:user-detail',
related_view_kwargs={'user_id': '<user._id>'},
)
bcc_sender = ser.BooleanField(
required=False,
default=False,
help_text='If true, BCCs the sender, giving them a copy of the email message they sent.',
)
reply_to = ser.BooleanField(
default=False,
help_text='Whether to set the sender\'s username as the `Reply-To` header in the email.',
)

def create(self, validated_data):
def to_internal_value(self, data):
instituion_id = data.pop('institution', None)
message_recipient_id = data.pop('message_recipient', None)
data = super().to_internal_value(data)

if instituion_id:
data['institution'] = instituion_id

if message_recipient_id:
data['message_recipient'] = message_recipient_id
return data

def create(self, validated_data) -> NodeRequest:
auth = get_user_auth(self.context['request'])

if not auth.user:
raise exceptions.PermissionDenied

Expand All @@ -105,26 +157,74 @@ 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)
request_type = validated_data.get('request_type')
if not request_type:
raise ValidationError('You must specify a valid request_type.')

match request_type:
case NodeRequestTypes.ACCESS.value:
return self.make_node_access_request(node, validated_data)
case NodeRequestTypes.INSTITUTIONAL_REQUEST.value:
return self.make_node_institutional_access_request(node, validated_data)
case _:
raise NotImplementedError(f'Request type "{request_type}" not implemented.')

def make_node_access_request(self, node, validated_data) -> NodeRequest:
return self._create_node_request(node, validated_data)

def make_node_institutional_access_request(self, node, validated_data) -> NodeRequest:
node_request = self._create_node_request(node, validated_data)
sender = self.context['request'].user
message_recipient_id = validated_data.get('message_recipient')
institution_id = validated_data.get('institution')
try:
recipient = OSFUser.objects.get(guids___id=message_recipient_id)
except OSFUser.DoesNotExist:
recipient = None

institution = Institution.objects.get(_id=institution_id)

if recipient:
if not recipient.is_affiliated_with_institution(institution):
raise PermissionDenied(f"User {recipient._id} is not affiliated with the institution.")
send_mail(
to_addr=recipient.username,
mail=NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST,
user=recipient,
sender=sender,
bcc_addr=validated_data.get('bcc_sender', []),
reply_to=validated_data.get('reply_to', None),
recipient=recipient,
comment=validated_data['comment'],
institution=institution,
osf_url=settings.DOMAIN,
node=node_request.target,
)

if request_type != RequestTypes.ACCESS.value:
raise exceptions.ValidationError('You must specify a valid request_type.')
return node_request

def _create_node_request(self, node, validated_data) -> NodeRequest:
creator = self.context['request'].user
request_type = validated_data['request_type']
comment = validated_data.get('comment', '')
requested_permissions = validated_data.get('requested_permissions')
try:
node_request = NodeRequest.objects.create(
target=node,
creator=auth.user,
creator=creator,
comment=comment,
machine_state=DefaultStates.INITIAL.value,
request_type=request_type,
requested_permissions=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)
raise Conflict(f"Users may not have more than one {request_type} request per node.")

node_request.run_submit(creator)
return node_request


class PreprintRequestSerializer(RequestSerializer):
class Meta:
type_ = 'preprint-requests'
Expand Down
6 changes: 3 additions & 3 deletions api_tests/registries_moderation/test_submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
)
Expand All @@ -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()

Expand Down
Loading

0 comments on commit 73e32d0

Please sign in to comment.