Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/develop' into feature/preprint…
Browse files Browse the repository at this point in the history
…s-doi-versioning
  • Loading branch information
cslzchen committed Jan 22, 2025
2 parents 4de0b2b + 3f17b5c commit 5deda2b
Show file tree
Hide file tree
Showing 41 changed files with 1,937 additions and 208 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO.

25.01.0 (2025-01-21)
====================

- Institutional Access Curator - BE Release

24.11.0 (2024-12-11)
====================
- Institutional Dashboard Project Bugfix Release
Expand Down
1 change: 1 addition & 0 deletions api/institutions/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class InstitutionSerializer(JSONAPISerializer):
ser.CharField(read_only=True),
permission='view_institutional_metrics',
)
institutional_request_access_enabled = ser.BooleanField(read_only=True)
links = LinksField({
'self': 'get_api_url',
'html': 'get_absolute_html_url',
Expand Down
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
46 changes: 39 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,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
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 not institution.institutional_request_access_enabled:
raise exceptions.PermissionDenied({'institution': 'Institutional request access is not enabled.'})

if get_user_auth(request).user.is_institutional_admin_at(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
133 changes: 116 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,116 @@ 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
"""
institution_id = data.pop('institution', None)
message_recipient_id = data.pop('message_recipient', None)
data = super().to_internal_value(data)

if institution_id:
data['institution'] = institution_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)
node_request.is_institutional_request = True
node_request.save()
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
39 changes: 37 additions & 2 deletions api/users/permissions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from osf.models import OSFUser
from rest_framework import permissions
from rest_framework import permissions, exceptions

from osf.models import OSFUser, Institution
from osf.models.user_message import MessageTypes


class ReadOnlyOrCurrentUser(permissions.BasePermission):
Expand Down Expand Up @@ -47,3 +49,36 @@ def has_permission(self, request, view):
def has_object_permission(self, request, view, obj):
assert isinstance(obj, OSFUser), f'obj must be a User, got {obj}'
return not obj.is_registered


class UserMessagePermissions(permissions.BasePermission):
"""
Custom permission to allow only institutional admins to create certain types of UserMessages.
"""
def has_permission(self, request, view) -> bool:
"""
Validate if the user has permission to perform the requested action.
Args:
request: The HTTP request.
view: The view handling the request.
Returns:
bool: True if the user has the required permission, False otherwise.
"""
user = request.user
if not user or user.is_anonymous:
return False

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': 'Specified institution does not exist.'})

message_type = request.data.get('message_type')
if message_type == MessageTypes.INSTITUTIONAL_REQUEST:
return user.is_institutional_admin_at(institution) and institution.institutional_request_access_enabled
else:
raise exceptions.ValidationError('Not valid message type.')
Loading

0 comments on commit 5deda2b

Please sign in to comment.