Skip to content

Commit

Permalink
Add new UserMessage feature for Institutional Access
Browse files Browse the repository at this point in the history
  • Loading branch information
John Tordoff committed Dec 5, 2024
1 parent 869c146 commit f7a73c2
Show file tree
Hide file tree
Showing 13 changed files with 537 additions and 5 deletions.
66 changes: 64 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,63 @@ 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.
"""
if request.method != 'POST':
return False

user = request.user
if not user or user.is_anonymous:
return False

message_type = request.data.get('message_type')
if message_type == MessageTypes.INSTITUTIONAL_REQUEST:
return self._validate_institutional_request(request, user)

return False

def _validate_institutional_request(self, request, user: OSFUser) -> bool:
"""
Validate the user's permissions for creating an `INSTITUTIONAL_REQUEST` message.
Args:
request: The HTTP request containing the institution ID.
user: The user making the request.
Returns:
bool: True if the user has the required permission.
"""
institution_id = request.data.get('institution')
if not institution_id:
raise exceptions.ValidationError({'institution': 'Institution ID is required.'})

institution = self._get_institution(institution_id)

if not user.is_institutional_admin(institution):
raise exceptions.NotAuthenticated('You are not an admin of the specified institution.')

return True

def _get_institution(self, institution_id: str) -> Institution:
"""
Retrieve the institution by its ID.
Args:
institution_id (str): The ID of the institution.
Returns:
Institution: The retrieved institution.
"""
try:
return Institution.objects.get(_id=institution_id)
except Institution.DoesNotExist:
raise exceptions.ValidationError({'institution': 'Specified institution does not exist.'})
117 changes: 115 additions & 2 deletions api/users/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Any, Dict

from django.utils import timezone
from jsonschema import validate, Draft7Validator, ValidationError as JsonSchemaValidationError
from rest_framework import exceptions
Expand All @@ -19,16 +21,26 @@
JSONAPIListField,
ShowIfCurrentUser,
)
from api.base.utils import absolute_reverse, default_node_list_queryset, get_user_auth, is_deprecated, hashids
from api.base.utils import (
absolute_reverse,
default_node_list_queryset,
get_user_auth,
is_deprecated,
hashids,
get_object_or_error,
)

from api.base.versioning import get_kebab_snake_case_field
from api.nodes.serializers import NodeSerializer, RegionRelationshipField
from framework.auth.views import send_confirm_email_async
from osf.exceptions import ValidationValueError, ValidationError, BlockedEmailError
from osf.models import Email, Node, OSFUser, Preprint, Registration
from osf.models import Email, Node, OSFUser, Preprint, Registration, UserMessage, Institution
from osf.models.user_message import MessageTypes
from osf.models.provider import AbstractProviderGroupObjectPermission
from osf.utils.requests import string_type_request_headers
from website.profile.views import update_osf_help_mails_subscription, update_mailchimp_subscription
from website.settings import MAILCHIMP_GENERAL_LIST, OSF_HELP_LIST, CONFIRM_REGISTRATIONS_BY_EMAIL
from website.util import api_v2_url


class SocialField(ser.DictField):
Expand Down Expand Up @@ -657,3 +669,104 @@ def update(self, instance, validated_data):

class UserNodeSerializer(NodeSerializer):
filterable_fields = NodeSerializer.filterable_fields | {'current_user_permissions'}


class UserMessageSerializer(JSONAPISerializer):
"""
Serializer for creating and managing `UserMessage` instances.
Attributes:
message_text (CharField): The text content of the message.
message_type (ChoiceField): The type of message being sent, restricted to `MessageTypes`.
institution (RelationshipField): The institution related to the message. Required.
user (RelationshipField): The recipient of the message.
"""
id = IDField(read_only=True, source='_id')
message_text = ser.CharField(
required=True,
help_text='The content of the message to be sent.',
)
message_type = ser.ChoiceField(
choices=MessageTypes.choices,
required=True,
help_text='The type of message being sent. Must match one of the defined `MessageTypes`.',
)
institution = RelationshipField(
related_view='institutions:institution-detail',
related_view_kwargs={'institution_id': '<institution._id>'},
help_text='The institution associated with this message. This field is required.',
)
user = RelationshipField(
related_view='users:user-detail',
related_view_kwargs={'user_id': '<recipient._id>'},
help_text='The recipient of the message.',
)

def get_absolute_url(self, obj: UserMessage) -> str:
return api_v2_url(
'users:user-messages',
params={
'user_id': self.context['request'].parser_context['kwargs']['user_id'],
'version': self.context['request'].parser_context['kwargs']['version'],
},
)

def to_internal_value(self, data):
instituion_id = data.pop('institution')
data = super().to_internal_value(data)
data['institution'] = instituion_id
return data

class Meta:
type_ = 'user-message'

def create(self, validated_data: Dict[str, Any]) -> UserMessage:
"""
Creates a `UserMessage` instance based on validated data.
Args:
validated_data (Dict[str, Any]): The data validated by the serializer.
Raises:
ValidationError: If required validations fail (e.g., sender not an institutional admin,
or recipient not affiliated with the institution).
Returns:
UserMessage: The created `UserMessage` instance.
"""
request = self.context['request']
sender = request.user

recipient = get_object_or_error(
OSFUser,
self.context['view'].kwargs['user_id'],
request,
'user',
)

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

institution = get_object_or_error(
Institution,
institution_id,
request,
'institution',
)

if not sender.is_institutional_admin(institution):
raise exceptions.ValidationError({'sender': 'Only institutional adminstraters can create messages.'})

if not recipient.is_affiliated_with_institution(institution):
raise exceptions.ValidationError(
{'user': 'Can not send to recipient that is not affiliated with the provided institution.'},
)

return UserMessage.objects.create(
sender=sender,
recipient=recipient,
institution=institution,
message_type=MessageTypes.INSTITUTIONAL_REQUEST,
message_text=validated_data['message_text'],
)
1 change: 1 addition & 0 deletions api/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
re_path(r'^(?P<user_id>\w+)/draft_preprints/$', views.UserDraftPreprints.as_view(), name=views.UserDraftPreprints.view_name),
re_path(r'^(?P<user_id>\w+)/registrations/$', views.UserRegistrations.as_view(), name=views.UserRegistrations.view_name),
re_path(r'^(?P<user_id>\w+)/settings/$', views.UserSettings.as_view(), name=views.UserSettings.view_name),
re_path(r'^(?P<user_id>\w+)/messages/$', views.UserMessageView.as_view(), name=views.UserMessageView.view_name),
re_path(r'^(?P<user_id>\w+)/quickfiles/$', views.UserQuickFiles.as_view(), name=views.UserQuickFiles.view_name),
re_path(r'^(?P<user_id>\w+)/relationships/institutions/$', views.UserInstitutionsRelationship.as_view(), name=views.UserInstitutionsRelationship.view_name),
re_path(r'^(?P<user_id>\w+)/settings/emails/$', views.UserEmailsList.as_view(), name=views.UserEmailsList.view_name),
Expand Down
22 changes: 22 additions & 0 deletions api/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from api.addons.views import AddonSettingsMixin
from api.base import permissions as base_permissions
from api.users.permissions import UserMessagePermissions
from api.base.waffle_decorators import require_flag
from api.base.exceptions import Conflict, UserGone, Gone
from api.base.filters import ListFilterMixin, PreprintFilterMixin
Expand Down Expand Up @@ -55,6 +56,7 @@
UserAccountExportSerializer,
ReadEmailUserDetailSerializer,
UserChangePasswordSerializer,
UserMessageSerializer,
)
from django.contrib.auth.models import AnonymousUser
from django.http import JsonResponse
Expand Down Expand Up @@ -957,3 +959,23 @@ def perform_destroy(self, instance):
else:
user.remove_unconfirmed_email(email)
user.save()


class UserMessageView(JSONAPIBaseView, generics.CreateAPIView):
"""
List and create UserMessages for a user.
"""
permission_classes = (
drf_permissions.IsAuthenticated,
base_permissions.TokenHasScope,
UserMessagePermissions,
)

required_read_scopes = [CoreScopes.USERS_READ]
required_write_scopes = [CoreScopes.USERS_WRITE]
parser_classes = (JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON)

serializer_class = UserMessageSerializer

view_category = 'users'
view_name = 'user-messages'
137 changes: 137 additions & 0 deletions api_tests/users/views/test_user_message_institutional_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
from unittest import mock
import pytest
from osf.models.user_message import MessageTypes, UserMessage
from api.base.settings.defaults import API_BASE
from osf_tests.factories import (
AuthUserFactory,
InstitutionFactory
)

@pytest.mark.django_db
class TestUserMessageInstitutionalAccess:
"""
Tests for `UserMessage`.
"""

@pytest.fixture()
def institution(self):
return InstitutionFactory()

@pytest.fixture()
def user(self):
return AuthUserFactory()

@pytest.fixture()
def noncontrib(self):
return AuthUserFactory()

@pytest.fixture()
def user_with_affiliation(self, institution):
user = AuthUserFactory()
user.add_or_update_affiliated_institution(institution)
return user

@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 url_with_affiliation(self, user_with_affiliation):
return f'/{API_BASE}users/{user_with_affiliation._id}/messages/'

@pytest.fixture()
def url_without_affiliation(self, user):
return f'/{API_BASE}users/{user._id}/messages/'

@pytest.fixture()
def payload(self, institution, user):
return {
'data': {
'attributes': {
'message_text': 'Requesting user access for collaboration',
'message_type': MessageTypes.INSTITUTIONAL_REQUEST.value,
},
'relationships': {
'institution': {
'data': {'id': institution._id, 'type': 'institutions'},
},
},
'type': 'user-message'
}
}

@mock.patch('osf.models.user_message.send_mail')
def test_institutional_admin_can_create_message(self, mock_send_mail, app, institutional_admin, institution, url_with_affiliation, payload):
"""
Ensure an institutional admin can create a `UserMessage` with a `message` and `institution`.
"""
mock_send_mail.return_value = mock.MagicMock()

res = app.post_json_api(
url_with_affiliation,
payload,
auth=institutional_admin.auth
)
assert res.status_code == 201
data = res.json['data']

user_message = UserMessage.objects.get(sender=institutional_admin)

assert user_message.message_text == payload['data']['attributes']['message_text']
assert user_message.institution == institution

mock_send_mail.assert_called_once()
assert mock_send_mail.call_args[1]['to_addr'] == user_message.recipient.username
assert 'Requesting user access for collaboration' in mock_send_mail.call_args[1]['message_text']
assert user_message._id == data['id']

def test_unauthenticated_user_cannot_create_message(self, app, user, url_with_affiliation, payload):
"""
Ensure that unauthenticated users cannot create a `UserMessage`.
"""
res = app.post_json_api(url_with_affiliation, payload, expect_errors=True)
assert res.status_code == 401
assert 'Authentication credentials were not provided' in res.json['errors'][0]['detail']

def test_non_institutional_admin_cannot_create_message(self, app, noncontrib, user, url_with_affiliation, payload):
"""
Ensure a non-institutional admin cannot create a `UserMessage`, even with valid data.
"""
res = app.post_json_api(url_with_affiliation, payload, auth=noncontrib.auth, expect_errors=True)
assert res.status_code == 401

def test_request_without_institution(self, app, institutional_admin, user, url_with_affiliation, payload):
"""
Test that a `UserMessage` can be created without specifying an institution, and `institution` is None.
"""
del payload['data']['relationships']['institution']

res = app.post_json_api(url_with_affiliation, payload, auth=institutional_admin.auth, expect_errors=True)
assert res.status_code == 400

def test_missing_message_fails(self, app, institutional_admin, user, url_with_affiliation, payload):
"""
Ensure a `UserMessage` cannot be created without a `message` attribute.
"""
del payload['data']['attributes']['message_text']

res = app.post_json_api(url_with_affiliation, payload, auth=institutional_admin.auth, expect_errors=True)
assert res.status_code == 400

def test_admin_cannot_message_user_outside_institution(
self,
app,
institutional_admin,
url_without_affiliation,
payload,
user
):
"""
Ensure that an institutional admin cannot create a `UserMessage` for a user who is not affiliated with their institution.
"""
res = app.post_json_api(url_without_affiliation, payload, auth=institutional_admin.auth, expect_errors=True)
assert res.status_code == 400
assert 'Can not send to recipient that is not affiliated with the provided institution.'\
in res.json['errors'][0]['detail']
Loading

0 comments on commit f7a73c2

Please sign in to comment.