Skip to content

Commit

Permalink
Merge pull request CenterForOpenScience#10826 from Johnetordoff/insti…
Browse files Browse the repository at this point in the history
…tutional-access-node-request-improvements

[ENG-6666] NodeRequest improvements for Institutional Access Project
  • Loading branch information
Johnetordoff authored Dec 16, 2024
2 parents 7068791 + efc5dd2 commit 66df96c
Show file tree
Hide file tree
Showing 12 changed files with 605 additions and 102 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
43 changes: 36 additions & 7 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 rest_framework import exceptions, 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 All @@ -32,7 +36,7 @@ def has_object_permission(self, request, view, obj):
raise ValueError(f'Not a request-related model: {obj}')

if not node.access_requests_enabled:
return False
raise exceptions.PermissionDenied(f'{node._id} does not have Access Requests enabled')

is_requester = target is not None and target.creator == auth.user or trigger == DefaultTriggers.SUBMIT.value
is_node_admin = node.has_permission(auth.user, osf_permissions.ADMIN)
Expand All @@ -52,7 +56,32 @@ 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
return False


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

institution_id = request.data.get('institution')
if not institution_id:
raise exceptions.ValidationError({'institution': 'Institution is required.'})

try:
institution = Institution.objects.get(_id=institution_id)
except Institution.DoesNotExist:
raise exceptions.ValidationError({'institution': 'Institution is does not exist.'})

if get_user_auth(request).user.is_institutional_admin(institution):
return True
else:
raise exceptions.PermissionDenied({'institution': 'You do not have permission to perform this action for this institution.'})


class PreprintRequestPermission(drf_permissions.BasePermission):
Expand Down
131 changes: 114 additions & 17 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 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,40 +112,114 @@ def get_target_url(self, obj):
},
)

class NodeRequestCreateSerializer(NodeRequestSerializer):
request_type = ser.ChoiceField(required=True, choices=RequestTypes.choices())

def create(self, validated_data):
auth = get_user_auth(self.context['request'])
if not auth.user:
raise exceptions.PermissionDenied
class NodeRequestCreateSerializer(NodeRequestSerializer):
request_type = ser.ChoiceField(read_only=False, required=False, choices=NodeRequestTypes.choices())
message_recipient = RelationshipField(
help_text='An optional user who will receive 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 to_internal_value(self, data):
"""
Retrieves the id value from `RelationshipField` fields
"""
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 get_node_and_validate_non_contributor(self, auth):
"""
Ensures request user isn't already a contributor.
"""
try:
node = self.context['view'].get_target()
return self.context['view'].get_target()
except exceptions.PermissionDenied:
node = self.context['view'].get_target(check_object_permissions=False)
if auth.user in node.contributors:
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)
def create(self, validated_data) -> NodeRequest:
auth = get_user_auth(self.context['request'])
if not auth.user:
raise exceptions.PermissionDenied

if request_type != RequestTypes.ACCESS.value:
raise exceptions.ValidationError('You must specify a valid request_type.')
node = self.get_node_and_validate_non_contributor(auth)

request_type = validated_data.get('request_type')
match request_type:
case NodeRequestTypes.ACCESS.value:
return self._create_node_request(node, validated_data)
case NodeRequestTypes.INSTITUTIONAL_REQUEST.value:
return self.make_node_institutional_access_request(node, validated_data)
case _:
raise ValidationError('You must specify a valid request_type.')

def make_node_institutional_access_request(self, node, validated_data) -> NodeRequest:
sender = self.context['request'].user
node_request = self._create_node_request(node, validated_data)
institution = Institution.objects.get(_id=validated_data['institution'])
recipient = OSFUser.load(validated_data.get('message_recipient'))

if recipient:
if not recipient.is_affiliated_with_institution(institution):
raise PermissionDenied(f"User {recipient._id} is not affiliated with the institution.")

if validated_data['comment']:
send_mail(
to_addr=recipient.username,
mail=NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST,
user=recipient,
sender=sender,
bcc_addr=[sender.username] if validated_data['bcc_sender'] else None,
reply_to=sender.username if validated_data['reply_to'] else None,
recipient=recipient,
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) -> 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):
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 66df96c

Please sign in to comment.