Skip to content

Commit

Permalink
add NodeRequest improvements for Institutional Access project
Browse files Browse the repository at this point in the history
  • Loading branch information
John Tordoff committed Dec 4, 2024
1 parent 869c146 commit c8c6264
Show file tree
Hide file tree
Showing 15 changed files with 446 additions and 110 deletions.
58 changes: 58 additions & 0 deletions api/base/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,61 @@ def parse(self, stream, media_type=None, parser_context=None):
else:
res['query']['bool']['filter'].append({'term': {key: val}})
return res


class JSONAPIDetailParser(JSONParser):
"""
Parses JSON-serialized data, flattens relationships, and adds them to the serializer's data.
"""
media_type = 'application/vnd.api+json'

def parse_relationships(self, relationships):
"""
Parses and flattens the relationships field from the JSON API payload.
"""
if not isinstance(relationships, dict):
raise JSONAPIException(source={'pointer': '/data/relationships'}, detail=NO_RELATIONSHIPS_ERROR)

flattened = {}
for key, value in relationships.items():
data = value.get('data')
if isinstance(data, list):
flattened[key] = [{'id': item['id'], 'type': item['type']} for item in data]
elif isinstance(data, dict):
flattened[key] = {'id': data['id'], 'type': data['type']}
else:
raise JSONAPIException(source={'pointer': f'/data/relationships/{key}/data'}, detail=NO_DATA_ERROR)
return flattened

def parse(self, stream, media_type=None, parser_context=None):
"""
Parses the incoming bytestream as JSON and processes relationships.
"""
parsed_data = super().parse(stream, media_type=media_type, parser_context=parser_context)

if not isinstance(parsed_data, dict):
raise ParseError(detail='Expected a dictionary of items.')
data = parsed_data.get('data')

if not data:
raise JSONAPIException(source={'pointer': '/data'}, detail=NO_DATA_ERROR)

attributes = data.get('attributes', {})
relationships = data.get('relationships', {})

if relationships:
flattened_relationships = self.parse_relationships(relationships)
attributes.update(flattened_relationships)

attributes['type'] = data.get('type')
if 'id' in data:
attributes['id'] = data['id']

return attributes


class JSONAPIDetailParserForRegularJSON(JSONAPIDetailParser):
"""
Allows same processing as JSONAPIDetailParser to occur for requests with application/json media type.
"""
media_type = 'application/json'
7 changes: 5 additions & 2 deletions api/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
from api.base.pagination import CommentPagination, NodeContributorPagination, MaxSizePagination
from api.base.parsers import (
JSONAPIRelationshipParser,
JSONAPIDetailParser,
JSONAPIDetailParserForRegularJSON,
JSONAPIRelationshipParserForRegularJSON,
JSONAPIMultipleRelationshipsParser,
JSONAPIMultipleRelationshipsParserForRegularJSON,
Expand Down Expand Up @@ -125,7 +127,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
Expand Down Expand Up @@ -2239,12 +2241,13 @@ class NodeRequestListCreate(JSONAPIBaseView, generics.ListCreateAPIView, ListFil
drf_permissions.IsAuthenticatedOrReadOnly,
base_permissions.TokenHasScope,
NodeRequestPermission,
InstitutionalAdminRequestType,
)

required_read_scopes = [CoreScopes.NODE_REQUESTS_READ]
required_write_scopes = [CoreScopes.NODE_REQUESTS_WRITE]

parser_classes = (JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON)
parser_classes = (JSONAPIDetailParser, JSONAPIDetailParserForRegularJSON)

serializer_class = NodeRequestSerializer

Expand Down
39 changes: 35 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,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', {}).get('id')
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
89 changes: 77 additions & 12 deletions api/requests/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -89,8 +111,17 @@ 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': '<id>'},
required=False,
)

def to_internal_value(self, data):
return data

def create(self, validated_data):
auth = get_user_auth(self.context['request'])
Expand All @@ -105,26 +136,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', {}).get('id')
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'
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 c8c6264

Please sign in to comment.